2026-09-09 00:48:09 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Modules\Core\Order\Filament\Extensions;
|
|
|
|
|
|
|
|
|
|
use Filament\Actions\BulkAction;
|
2026-09-25 10:08:57 +03:00
|
|
|
use Filament\Support\Colors\Color;
|
2026-09-09 00:48:09 +03:00
|
|
|
use Filament\Support\Exceptions\Halt;
|
2026-09-25 10:08:57 +03:00
|
|
|
use Filament\Tables\Columns\Layout\Panel;
|
|
|
|
|
use Filament\Tables\Columns\TextColumn;
|
2026-09-09 00:48:09 +03:00
|
|
|
use Filament\Tables\Table;
|
2026-09-25 10:08:57 +03:00
|
|
|
use Illuminate\Support\Facades\Blade;
|
|
|
|
|
use Illuminate\Support\Facades\URL;
|
|
|
|
|
use Illuminate\Support\HtmlString;
|
2026-09-09 00:48:09 +03:00
|
|
|
use Lunar\Admin\Support\Extending\BaseExtension;
|
2026-09-25 10:08:57 +03:00
|
|
|
use Lunar\Models\OrderLine;
|
|
|
|
|
use Modules\Core\File\Models\File;
|
2026-09-09 00:48:09 +03:00
|
|
|
|
|
|
|
|
/**
|
2026-09-25 10:08:57 +03:00
|
|
|
* extendTable() has two unrelated jobs: the "bulk_refund" toolbar-action
|
|
|
|
|
* fix (see fixFailureNotification()'s own docblock — a genuine Filament
|
|
|
|
|
* bug), and adding a "Custom Fields" entry to each order line's own
|
|
|
|
|
* collapsible details dropdown (Lunar\Admin\...\OrderItemsTable::
|
|
|
|
|
* getOrderLinesTableColumns()'s Panel — the same one already showing
|
|
|
|
|
* stock level, notes, and the price_breakdowns table) — the shopper's
|
|
|
|
|
* answers to Product::$custom_fields (a reference photo, personalization
|
|
|
|
|
* text, ...), stored on OrderLine.meta by 3dealer's CartController::
|
|
|
|
|
* customFieldsMeta() and, until now, never shown anywhere in the admin.
|
|
|
|
|
*
|
|
|
|
|
* Finds that Panel via $table->getCollapsibleColumnsLayout() — NOT
|
|
|
|
|
* $table->getColumns(), which two earlier attempts at this both reached
|
|
|
|
|
* for. HasColumns::pushColumns() flattens every Panel/Split into leaf
|
|
|
|
|
* columns at table-build time and stores THAT flat list as
|
|
|
|
|
* $this->columns (what getColumns() returns); the original nested
|
|
|
|
|
* Panel/Stack objects actually used for rendering are kept separately —
|
|
|
|
|
* in $this->columnsLayout for a non-collapsible layout component, or
|
|
|
|
|
* $this->collapsibleColumnsLayout for one that IS collapsible (this
|
|
|
|
|
* order-lines Panel is, via ->collapsible()). So `$column instanceof
|
|
|
|
|
* Panel` over getColumns() can never match anything — Panel/Split
|
|
|
|
|
* instances simply never appear in that array at all — and a fix built
|
|
|
|
|
* on that check silently mutated nothing. A first attempt building a
|
|
|
|
|
* brand new Panel and re-calling $table->columns() on top of the
|
|
|
|
|
* existing setup fixed nothing either and instead rendered as a stray
|
|
|
|
|
* empty extra column outside the dropdown (caught by actually opening
|
|
|
|
|
* the order page). Mutates the found Panel's Stack in place via
|
|
|
|
|
* Stack::schema(), the one part of both earlier attempts that actually
|
|
|
|
|
* worked once the right object was found.
|
|
|
|
|
*
|
|
|
|
|
* Its own TextColumn rather than reusing the Panel's existing KeyValue:
|
|
|
|
|
* KeyValue's own Blade view HTML-escapes every value ({{ $value }}),
|
|
|
|
|
* which can't render a clickable link for a file answer.
|
2026-09-09 00:48:09 +03:00
|
|
|
*/
|
|
|
|
|
class OrderItemsTableExtension extends BaseExtension
|
|
|
|
|
{
|
|
|
|
|
public function extendTable(Table $table): Table
|
|
|
|
|
{
|
2026-09-25 10:08:57 +03:00
|
|
|
if ($table->getCollapsibleColumnsLayout() instanceof Panel) {
|
|
|
|
|
$this->addCustomFieldsColumn($table->getCollapsibleColumnsLayout());
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 00:48:09 +03:00
|
|
|
return $table->toolbarActions(
|
|
|
|
|
array_map(
|
|
|
|
|
fn ($action) => $action instanceof BulkAction && $action->getName() === 'bulk_refund'
|
|
|
|
|
? $this->fixFailureNotification($action)
|
|
|
|
|
: $action,
|
|
|
|
|
$table->getToolbarActions(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 10:08:57 +03:00
|
|
|
private function addCustomFieldsColumn(Panel $panel): void
|
|
|
|
|
{
|
|
|
|
|
$stack = $panel->getComponents()[0] ?? null;
|
|
|
|
|
|
|
|
|
|
if ($stack === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$stack->schema([
|
|
|
|
|
...$stack->getComponents(),
|
|
|
|
|
TextColumn::make('custom_fields')
|
|
|
|
|
->label('Custom Fields')
|
|
|
|
|
->visible(fn (OrderLine $record) => filled($record->meta['custom_fields'] ?? null))
|
|
|
|
|
->getStateUsing(fn (OrderLine $record) => $this->renderCustomFields($record))
|
|
|
|
|
->html(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Same table markup/classes as this Panel's own existing KeyValue
|
|
|
|
|
* component (Lunar\Admin's price_breakdowns, right above this in the
|
|
|
|
|
* dropdown — see lunarpanel::tables.components.key-value) for visual
|
|
|
|
|
* consistency, rebuilt here rather than reused: KeyValue's Blade view
|
|
|
|
|
* HTML-escapes every value ({{ $value }}), which can't render a
|
|
|
|
|
* thumbnail/download link for a file answer.
|
|
|
|
|
*/
|
|
|
|
|
private function renderCustomFields(OrderLine $record): HtmlString
|
|
|
|
|
{
|
|
|
|
|
$rows = collect($record->meta['custom_fields'] ?? [])
|
|
|
|
|
->map(fn (array $field) => sprintf(
|
|
|
|
|
'<tr class="divide-x divide-gray-950/10 dark:divide-white/10"><td class="p-2 font-medium whitespace-nowrap">%s</td><td class="p-2">%s</td></tr>',
|
|
|
|
|
e($field['label']),
|
|
|
|
|
$field['type'] === 'file' ? $this->fileCell($field) : e($field['value'] ?? ''),
|
|
|
|
|
))
|
|
|
|
|
->implode('');
|
|
|
|
|
|
|
|
|
|
return new HtmlString(
|
|
|
|
|
'<div class="w-full mt-2 overflow-hidden overflow-x-auto ring-1 ring-inset ring-gray-950/10 dark:ring-white/10 rounded bg-white/70 dark:bg-white/5">'
|
|
|
|
|
.'<table class="min-w-full text-xs divide-y divide-gray-950/10 dark:divide-white/10"><tbody class="divide-y divide-gray-950/10 dark:divide-white/10">'
|
|
|
|
|
.$rows
|
|
|
|
|
.'</tbody></table></div>',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A thumbnail (previewable image types only — an inline-signed URL to
|
|
|
|
|
* the same File; see FileService::retrieve()) alongside an icon-only
|
|
|
|
|
* download link forcing Content-Disposition: attachment (FileService::
|
|
|
|
|
* download()) — two separate signed URLs, not one reused with a query
|
|
|
|
|
* string appended after signing, since a signature covers the exact
|
|
|
|
|
* query parameters present when it was minted.
|
|
|
|
|
*/
|
|
|
|
|
private function fileCell(array $field): string
|
|
|
|
|
{
|
|
|
|
|
$file = File::find($field['file_id'] ?? null);
|
|
|
|
|
|
|
|
|
|
if ($file === null) {
|
|
|
|
|
return __('lunarpanel::global.na');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$previewUrl = URL::temporarySignedRoute('files.download', now()->addHours(2), ['file' => $file->id]);
|
|
|
|
|
$downloadUrl = URL::temporarySignedRoute('files.download', now()->addHours(2), ['file' => $file->id, 'download' => 1]);
|
|
|
|
|
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
|
|
|
|
|
|
|
|
$thumbnail = in_array($file->mime, $previewable, true)
|
|
|
|
|
? sprintf(
|
|
|
|
|
'<a href="%s" target="_blank" rel="noopener"><img src="%s" alt="" style="width:2.5rem;height:2.5rem;object-fit:cover;border-radius:0.375rem;vertical-align:middle"></a>',
|
|
|
|
|
$previewUrl,
|
|
|
|
|
$previewUrl,
|
|
|
|
|
)
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
return sprintf(
|
|
|
|
|
'<div style="display:flex;align-items:center;gap:0.5rem">%s<span>%s</span><a href="%s" title="Download" style="color:rgb(%s);display:inline-flex">%s</a></div>',
|
|
|
|
|
$thumbnail,
|
|
|
|
|
e($file->original_name),
|
|
|
|
|
$downloadUrl,
|
|
|
|
|
Color::Blue[600],
|
|
|
|
|
Blade::render('<x-filament::icon icon="heroicon-o-arrow-down-tray" style="width:1rem;height:1rem"/>'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 00:48:09 +03:00
|
|
|
private function fixFailureNotification(BulkAction $action): BulkAction
|
|
|
|
|
{
|
|
|
|
|
$originalAction = $action->getActionFunction();
|
|
|
|
|
|
|
|
|
|
if ($originalAction === null) {
|
|
|
|
|
return $action;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $action->action(function (array $arguments) use ($action, $originalAction) {
|
|
|
|
|
try {
|
|
|
|
|
return $action->evaluate($originalAction, $arguments);
|
|
|
|
|
} catch (Halt $exception) {
|
|
|
|
|
$action->sendFailureNotification();
|
|
|
|
|
|
|
|
|
|
throw $exception;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|