Files
core/src/Shipping/Extensions/OrderShipmentsExtension.php
T

187 lines
7.7 KiB
PHP
Raw Normal View History

<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Illuminate\Support\Facades\URL;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Models\Shipment;
use Throwable;
/**
* Adds a "Shipments" section to the order page's main column — previously
* "Create Shipment" (Modules\Core\Shipping\Extensions\OrderViewExtension)
* had no counterpart anywhere on the order to actually SEE what it
* created (carrier, tracking reference, current status, whether a label's
* been printed or the shipment cancelled). One row per Shipment record —
* a Box Now order with several boxes shows one row per box/parcel (see
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService, which
* creates one Shipment row per parcel Box Now returns), not one row per
* "Create Shipment" click.
*
* Uses the extendInfolistSchema hook (main column — alongside shipping
* address, order lines, totals, transactions, timeline), not
* extendInfolistAsideSchema (sidebar) — a shipment list can grow long
* (multi-box Box Now orders, a re-dispatched order after a delivery
* failure) and reads more naturally as a main-column section like
* Transactions, not a compact sidebar entry.
*
* Each shipment renders as two inline-labelled lines (carrier + tracking
* reference, then status + timestamp) rather than a grid of individually
* stacked label/value blocks — Filament's own multi-column grid still
* collapses to one column below its lg breakpoint (1024px), which is
* exactly where the admin's main content area commonly sits with the
* sidebar open, so a 5-6 field grid reads as a wall of repeated labels
* there.
*
* "Print Label" opens Modules\Core\Shipping\Http\Controllers\
* DownloadShipmentLabelController via a short-lived signed URL — the same
* auth model (and Action wiring pattern) Lunar's own vendor PdfDownload
* action uses for order PDFs. Previously the only place that called
* CarrierFulfillmentInterface::printLabel() (Modules\Core\Shipping\
* Filament\Pages\ManagePickupManifests) discarded the returned bytes
* entirely — this is the first place that actually delivers a label to
* staff.
*/
class OrderShipmentsExtension extends ViewPageExtension
{
/**
* Inserted right after Transactions and before Timeline — vendor
* ManageOrder::getInfolistSchema() builds this array as
* [shipping, orderLines, orderTotals, transactions, timeline] (see
* Lunar\Admin\...\Concerns\DisplaysTransactions/DisplaysTimeline), so
* splicing at index 4 lands the new section there regardless of how
* many earlier entries any OTHER extension on this same hook has
* already added/removed — counting from the end (timeline is always
* last) would be equally fragile to some other extension appending
* its own section after timeline, so this anchors on the known
* vendor order instead.
*/
public function extendInfolistSchema(array $schema): array
{
array_splice($schema, 4, 0, [$this->shipmentsSection()]);
return $schema;
}
private function shipmentsSection(): Section
{
return Section::make('shipments')
->heading('Shipments')
->compact()
->collapsed(fn ($record) => $record->shipments->isEmpty())
->collapsible(fn ($record) => $record->shipments->isNotEmpty())
->schema([
RepeatableEntry::make('shipments')
->hiddenLabel()
->placeholder('No shipments have been created for this order yet.')
->contained(true)
->schema([
TextEntry::make('tracking_reference')
->label(fn (Shipment $record) => $this->carrierLabel($record))
->inlineLabel()
->copyable(),
TextEntry::make('status')
->label('Status')
->inlineLabel()
->state(fn (Shipment $record) => $this->statusLabel($record))
->badge()
->color(fn (Shipment $record) => $this->statusColor($record))
->helperText(fn (Shipment $record) => $this->helperText($record))
->suffixActions([
Action::make('print_label')
->label('Print Label')
->icon('heroicon-o-printer')
->url(fn (Shipment $record) => URL::temporarySignedRoute(
'shipments.label',
now()->addMinutes(5),
['shipment' => $record->id],
), shouldOpenInNewTab: true)
->visible(fn (Shipment $record) => ! $record->cancelled_at),
Action::make('cancel_shipment')
->label('Cancel')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->modalDescription('Cancels this shipment with the carrier. This cannot be undone.')
->action(fn (Shipment $record) => $this->cancel($record))
->visible(fn (Shipment $record) => ! $record->cancelled_at),
]),
]),
]);
}
private function carrierLabel(Shipment $record): string
{
return match ($record->carrier) {
'acs' => 'ACS',
'box-now' => 'Box Now',
default => (string) str($record->carrier)->title(),
};
}
private function helperText(Shipment $record): string
{
$parts = ['Created '.$record->created_at->format('Y-m-d H:i')];
if ($record->carrier === 'box-now' && $locker = $record->meta['locker_id'] ?? null) {
$parts[] = 'Locker '.$locker;
}
return implode(' · ', $parts);
}
private function statusLabel(Shipment $record): string
{
if ($record->cancelled_at) {
return 'Cancelled';
}
$latest = $record->latestShipmentInfo();
return $latest ? (string) str($latest->status->value)->replace('_', ' ')->title() : 'Pending';
}
private function statusColor(Shipment $record): string
{
if ($record->cancelled_at) {
return 'danger';
}
return match ($record->latestShipmentInfo()?->status?->value) {
'delivered' => 'success',
'failed', 'returned' => 'danger',
'in_transit', 'out_for_delivery', 'collected_from_sender' => 'warning',
default => 'gray',
};
}
private function cancel(Shipment $record): void
{
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $record->carrier]);
try {
$service->cancelShipment($record);
} catch (Throwable $e) {
report($e);
Notification::make()
->title('Failed to cancel shipment: '.$e->getMessage())
->color('danger')
->send();
return;
}
Notification::make()
->title('Shipment cancelled.')
->color('success')
->send();
}
}