status directly * with no audit trail, no branch validation, and no side effects. Our * replacement offers every status in the order's own branch (carrier or * pickup — see OrderStatusFlow::allOptions()), not just the guided next * step, so staff can freely revert to an earlier status too. It is a * PLAIN status write — picking 'dispatched' here does not create a real * shipment (see "Create Shipment" below for that). * * "Create Shipment" is its own separate header action, visible only for a * carrier order sitting at 'ready_for_dispatch' — this is the one action * that talks to a real carrier API and writes Order::status to * 'dispatched' as a side effect of that succeeding, so it needs its own * weight/locker inputs specific to that one real-world action, not * bundled into the general-purpose status select where they'd appear for * every revert/manual-override use of 'dispatched' too. The form branches * on which carrier the order actually uses * (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS) * gets a single TOTAL weight field for the whole shipment (ACS has no * per-package weight concept — one Weight value is sent alongside * Item_Quantity in the same ACS_Create_Voucher call, see * AcsFulfillmentService::createShipment()), pre-filled from the order's * own line weights (Modules\Core\Shipping\Support\WeightCalculator) but * still staff-editable, plus a package count (ShipmentRequest:: * $packageCount) — more than 1 issues a main voucher plus a * multi-part sub-voucher per extra package (persistMultipartVouchers()), * each recorded as its own Shipment row sharing the same total weight in * meta. Box Now, which bills by compartment size rather than * weight, gets a repeatable list of boxes (one row per physical parcel, * each with its own S/M/L size) instead — see * Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how * multiple boxes become multiple Shipment rows from one delivery request. * Box Now's locker is locked to read-only once the shopper's own checkout * selection ($order->shippingAddress->meta['box_now_locker']) is present * — staff can only fill it in manually for the (current, checkout-UI-less) * case where nothing set it yet. * * "Mark Paid" is a third, separate header action — Order::paid is * independent of `status` (see OrderStatusFlow's own docblock), so it * doesn't belong bundled into the status select either. Visible only when * the order's payment method doesn't auto-capture at checkout (currently * only cash-on-delivery — see OrderStatusFlow::canMarkPaid()); a * processor-managed method (Stripe) or an immediate-capture offline * method (cash-in-hand, bank-transfer) sets Order::paid automatically via * Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus, so this button * never appears for those. * * Also strips Lunar's own "Download PDF" header action (getDefaultHeaderActions() * in vendor ManageOrder) — its lunarpanel::pdf.order template does not meet * Greek AADE e-invoicing requirements, so it must not be offered as a * downloadable document until a compliant invoice generator exists. */ class OrderViewExtension extends ViewPageExtension { public function headerActions(array $actions): array { $actions = array_filter($actions, fn ($action) => method_exists($action, 'getName') ? ! in_array($action->getName(), ['download_pdf', 'update_status'], true) : true); $actions[] = $this->createShipmentAction(); $actions[] = $this->updateStatusAction(); $actions[] = $this->markPaidAction(); return $actions; } private function createShipmentAction(): Action { return Action::make('create_shipment') ->label('Create Shipment') ->icon('heroicon-o-truck') ->modalSubmitActionLabel('Create Shipment') ->schema(function (Order $record) { $isBoxNow = $this->service()->carrierFor($record) === 'box-now'; $lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null; if (! $isBoxNow) { return [ TextInput::make('weight') ->label('Total weight (kg)') ->numeric() ->minValue(0) ->default(fn () => round(WeightCalculator::totalKg($record->lines), 2) ?: null) ->helperText("Calculated from the order's line weights — adjust if needed, or leave blank to use the carrier's default. One figure for the whole shipment, not per package."), TextInput::make('package_count') ->label('Number of packages') ->numeric() ->integer() ->minValue(1) ->default(1) ->required() ->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'), ]; } return [ TextInput::make('destination_location_id') ->label('Box Now locker ID') ->default($lockerId) // Locked once the shopper's own checkout selection is // known — staff should not be able to redirect a // parcel to a different locker than the one the // customer picked. Only editable for the (current, // checkout-UI-less) case where nothing set it yet. ->disabled(filled($lockerId)) ->dehydrated() ->required() ->helperText($lockerId ? 'Set by the customer at checkout.' : 'No locker was selected at checkout — enter it manually.'), Repeater::make('boxes') ->label('Boxes') ->schema([ Select::make('size') ->label('Size') ->options(['S' => 'Small', 'M' => 'Medium', 'L' => 'Large']) ->default('S') ->native(false) ->required(), ]) ->defaultItems(1) ->addActionLabel('Add another box') ->minItems(1) ->helperText('One row per physical parcel — Box Now ships by compartment size, not weight.'), ]; }) ->action(function (Order $record, array $data, Action $action) { $result = $this->service()->createShipmentAndDispatch( $record, new ShipmentRequest( weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, packageCount: (int) ($data['package_count'] ?? 1), destinationLocationId: $data['destination_location_id'] ?? null, boxes: collect($data['boxes'] ?? [])->pluck('size')->all(), ), ); $this->notify($result); if (! $result->success) { $action->halt(); } }) ->visible(fn (Order $record) => $this->service()->canCreateShipment($record)); } private function updateStatusAction(): Action { return Action::make('update_status') ->label('Update Status') ->icon('heroicon-o-adjustments-horizontal') ->schema(fn (Order $record) => [ Select::make('to_status') ->label('New status') ->options(app(OrderStatusFlow::class)->allOptions($record)) ->default($record->status) ->native(false) ->required(), ]) ->action(function (Order $record, array $data, Action $action) { $to = $data['to_status']; $service = $this->service(); $result = match (true) { ($to === 'ready_for_dispatch' || $to === 'ready_for_pickup') && $record->status === 'processing' => $service->markReady($record), $to === 'picked_up' && $record->status === 'ready_for_pickup' => $service->markPickedUp($record), default => $service->transitionTo($record, $to), }; $this->notify($result); if (! $result->success) { $action->halt(); } }); } private function markPaidAction(): Action { return Action::make('mark_paid') ->label('Mark Paid') ->icon('heroicon-o-banknotes') ->color('success') ->requiresConfirmation() ->modalDescription('Confirms payment for this order has been received outside the system.') ->visible(fn (Order $record) => app(OrderStatusFlow::class)->canMarkPaid($record)) ->action(fn (Order $record) => $this->notify($this->service()->markPaid($record))); } private function service(): OrderFulfillmentService { return app(OrderFulfillmentService::class); } private function notify(OrderFulfillmentResult $result): void { Notification::make() ->title($result->message) ->color($result->success ? 'success' : 'danger') ->send(); } }