From 7edb8aa5a9a7724938677c5f3994e6031d696b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81uczy=C5=84ski?= Date: Tue, 22 Sep 2026 12:11:52 +0200 Subject: [PATCH 01/21] feat: work orders, material lots and pallets --- CHANGELOG.md | 4 + .../Web/Admin/TraceabilityController.php | 18 + .../Traceability/TraceabilityService.php | 79 ++-- .../seeders/TraceabilityDemoSeeder.php | 355 ++++++++++++++++++ backend/lang/en.json | 7 +- backend/lang/pl.json | 24 +- .../js/Pages/admin/traceability/Index.jsx | 260 ++++++++++--- .../Seeders/TraceabilityDemoSeederTest.php | 56 +++ backend/tests/Feature/TraceabilityTest.php | 47 +++ 9 files changed, 774 insertions(+), 76 deletions(-) create mode 100644 backend/database/seeders/TraceabilityDemoSeeder.php create mode 100644 backend/tests/Feature/Seeders/TraceabilityDemoSeederTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 50753f8c3..1ef3b4d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Changed + +- Traceability console: browse work orders, material lots and pallets in searchable, filterable tables and open a trace from the row instead of pasting a number; work orders are now traceable by their own order number. + ### Fixed - Prune expired demo tenants on PostgreSQL without conflicting checklist/user cascades; retain atomic rollback when production audit records prevent deletion. diff --git a/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php b/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php index 976d4b279..695b18d9e 100644 --- a/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php +++ b/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php @@ -3,6 +3,10 @@ namespace App\Http\Controllers\Web\Admin; use App\Http\Controllers\Controller; +use App\Models\Customer; +use App\Models\Line; +use App\Models\Material; +use App\Models\ProductType; use App\Models\SerialUnit; use App\Models\WorkOrder; use App\Services\Traceability\SerialTraceService; @@ -59,6 +63,13 @@ public function index(Request $request) 'components' => $this->tracer->componentLineJourneys($unit)['components'], 'data' => $this->mapSerial($this->serials->getHistory($unit)), ]; + } elseif ($workOrder = WorkOrder::where('order_no', $term)->first()) { + // The order number is what the shop floor knows - and what the + // console's own orders table links with. + $result = [ + 'type' => 'work_order', + 'data' => $this->tracer->workOrderTrace($workOrder), + ]; } elseif (WorkOrder::where('customer_order_no', $term)->exists()) { // Customer order number is non-unique → aggregate all matching WOs. $result = [ @@ -71,6 +82,13 @@ public function index(Request $request) return Inertia::render('admin/traceability/Index', [ 'term' => $term, 'result' => $result, + // id => name lookups for the browse tables, whose rows are synced + // collections carrying only the foreign keys. + 'lineNames' => Line::pluck('name', 'id'), + 'productTypeNames' => ProductType::pluck('name', 'id'), + 'customerNames' => Customer::pluck('name', 'id'), + 'materialNames' => Material::pluck('name', 'id'), + 'workOrderNumbers' => WorkOrder::pluck('order_no', 'id'), ]); } diff --git a/backend/app/Services/Traceability/TraceabilityService.php b/backend/app/Services/Traceability/TraceabilityService.php index 468ee8b69..082d54c8d 100644 --- a/backend/app/Services/Traceability/TraceabilityService.php +++ b/backend/app/Services/Traceability/TraceabilityService.php @@ -10,6 +10,7 @@ use App\Models\SerialUnit; use App\Models\WorkOrder; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; /** @@ -553,7 +554,34 @@ private function batchChain(Batch $batch): array */ public function customerOrderTrace(string $customerOrderNo): array { - $workOrders = WorkOrder::where('customer_order_no', $customerOrderNo) + return [ + 'customer_order_no' => $customerOrderNo, + 'work_orders' => $this->traceWorkOrders(WorkOrder::where('customer_order_no', $customerOrderNo)), + ]; + } + + /** + * The same descent as customerOrderTrace(), for the one work order the + * console was pointed at - by its order number or from the orders table. + */ + public function workOrderTrace(WorkOrder $workOrder): array + { + return [ + 'order_no' => $workOrder->order_no, + 'customer_order_no' => $workOrder->customer_order_no, + 'work_orders' => $this->traceWorkOrders(WorkOrder::whereKey($workOrder->getKey())), + ]; + } + + /** + * Work orders with their pallets and batches, each batch carrying the lots it + * produced and the component lots it consumed. + * + * @param Builder $query + */ + private function traceWorkOrders(Builder $query): Collection + { + $workOrders = $query ->with([ 'productType:id,name', 'pallets:id,work_order_id,batch_id,pallet_no,status', @@ -570,35 +598,32 @@ public function customerOrderTrace(string $customerOrderNo): array $batchIds = $workOrders->pluck('batches')->flatten(1)->pluck('id')->filter()->unique()->all(); $inputLotsByBatch = $this->inputLotsByBatch($batchIds); - return [ - 'customer_order_no' => $customerOrderNo, - 'work_orders' => $workOrders->map(fn ($wo) => [ - 'order_no' => $wo->order_no, - 'product' => $wo->productType?->name, - 'status' => $wo->status, - 'pallets' => $wo->pallets->map(fn ($p) => [ - 'pallet_no' => $p->pallet_no, - 'status' => $p->status instanceof PalletStatus ? $p->status->value : $p->status, - 'batch_lot' => $p->batch?->lot_number, + return $workOrders->map(fn ($wo) => [ + 'order_no' => $wo->order_no, + 'product' => $wo->productType?->name, + 'status' => $wo->status, + 'pallets' => $wo->pallets->map(fn ($p) => [ + 'pallet_no' => $p->pallet_no, + 'status' => $p->status instanceof PalletStatus ? $p->status->value : $p->status, + 'batch_lot' => $p->batch?->lot_number, + ])->values(), + 'batches' => $wo->batches->map(fn ($b) => [ + 'batch_number' => $b->batch_number, + 'lot_number' => $b->lot_number, + 'status' => $b->status, + 'output_lots' => $b->outputLots->map(fn ($o) => [ + 'lot_number' => $o->lot_number, + 'material' => $o->material?->name, + 'status' => $o->status, ])->values(), - 'batches' => $wo->batches->map(fn ($b) => [ - 'batch_number' => $b->batch_number, - 'lot_number' => $b->lot_number, - 'status' => $b->status, - 'output_lots' => $b->outputLots->map(fn ($o) => [ - 'lot_number' => $o->lot_number, - 'material' => $o->material?->name, - 'status' => $o->status, - ])->values(), - 'components' => collect($inputLotsByBatch[$b->id] ?? [])->map(fn ($lot) => [ - 'lot_number' => $lot->lot_number, - 'material' => $lot->material?->name, - 'supplier_lot_no' => $lot->supplier_lot_no, - 'status' => $lot->status, - ])->values(), + 'components' => collect($inputLotsByBatch[$b->id] ?? [])->map(fn ($lot) => [ + 'lot_number' => $lot->lot_number, + 'material' => $lot->material?->name, + 'supplier_lot_no' => $lot->supplier_lot_no, + 'status' => $lot->status, ])->values(), ])->values(), - ]; + ])->values(); } /** diff --git a/backend/database/seeders/TraceabilityDemoSeeder.php b/backend/database/seeders/TraceabilityDemoSeeder.php new file mode 100644 index 000000000..34cdc8311 --- /dev/null +++ b/backend/database/seeders/TraceabilityDemoSeeder.php @@ -0,0 +1,355 @@ + a semi-finished frame lot -> finished bikes on pallets, with serial + * numbers and a customer order on top. It is written straight to the genealogy + * tables rather than driven through allocation, so it traces the same whether + * or not lot tracking is switched on. Additive only - it touches no existing row. + */ +class TraceabilityDemoSeeder extends Seeder +{ + private const MARKER_ORDER = 'TR-FRAME-001'; + + private const CUSTOMER_ORDER = 'TR-CO-5001'; + + private User $operator; + + public function run(): void + { + if (WorkOrder::where('order_no', self::MARKER_ORDER)->exists()) { + $this->command?->info('Traceability demo already present - nothing to do.'); + + return; + } + + $operator = User::orderBy('id')->first(); + if (! $operator) { + $this->command?->warn('Traceability demo needs at least one user.'); + + return; + } + $this->operator = $operator; + + DB::transaction(function () { + [$line, $stations] = $this->seedLine(); + $materials = $this->seedMaterials(); + $lots = $this->seedRawLots($materials); + + $frameLot = $this->seedFrameOrder($line, $stations, $materials, $lots); + $this->seedFinishedBikeOrder($line, $stations, $materials, $lots, $frameLot); + $this->seedRunningBikeOrder($line, $stations, $lots, $frameLot); + $this->seedPendingBikeOrder($line); + }); + + $this->command?->info('Traceability demo seeded - open /admin/traceability and look for TR-.'); + } + + /** @return array{0: Line, 1: array} */ + private function seedLine(): array + { + $line = Line::firstOrCreate(['code' => 'TR-LINE'], ['name' => 'TR - Bike assembly', 'is_active' => true]); + + $stations = []; + foreach (['CUT' => ['Tube cutting', 40], 'WELD' => ['Frame welding', 24], 'PAINT' => ['Paint booth', 30], 'ASSY' => ['Final assembly', 12]] as $code => [$name, $rate]) { + $stations[$code] = Workstation::firstOrCreate( + ['code' => "TR-{$code}"], + ['line_id' => $line->id, 'name' => $name, 'ideal_rate_per_hour' => $rate, 'is_active' => true], + ); + } + + return [$line, $stations]; + } + + /** @return array */ + private function seedMaterials(): array + { + $typeId = MaterialType::orderBy('id')->value('id') + ?? MaterialType::create(['code' => 'TR-RAW', 'name' => 'TR - Raw material'])->id; + + $defs = [ + 'steel' => ['TR-MAT-STEEL', 'Steel tube 32x2', 'm'], + 'paint' => ['TR-MAT-PAINT', 'Powder paint RAL 3020', 'kg'], + 'frame' => ['TR-MAT-FRAME', 'Welded bike frame', 'pcs'], + 'bike' => ['TR-MAT-BIKE', 'City bike (finished)', 'pcs'], + ]; + + $materials = []; + foreach ($defs as $key => [$code, $name, $unit]) { + $materials[$key] = Material::firstOrCreate( + ['code' => $code], + ['name' => $name, 'material_type_id' => $typeId, 'unit_of_measure' => $unit, 'tracking_type' => 'batch', 'is_active' => true], + ); + } + + return $materials; + } + + /** @return array */ + private function seedRawLots(array $materials): array + { + $defs = [ + 'steel1' => ['TR-RAW-STEEL-01', 'steel', 600, 120, 'SUP-ST-7781', 'TR-CONT-0042', MaterialLot::STATUS_RELEASED, 21], + 'steel2' => ['TR-RAW-STEEL-02', 'steel', 600, 510, 'SUP-ST-7802', 'TR-CONT-0057', MaterialLot::STATUS_RELEASED, 6], + 'paint1' => ['TR-RAW-PAINT-01', 'paint', 50, 31.5, 'SUP-PT-3020-114', 'TR-DRUM-0009', MaterialLot::STATUS_RELEASED, 14], + // Held at inbound inspection and never consumed: a lot with no forward trace. + 'steel3' => ['TR-RAW-STEEL-03', 'steel', 600, 600, 'SUP-ST-7815', 'TR-CONT-0063', MaterialLot::STATUS_QUARANTINE, 2], + ]; + + $lots = []; + foreach ($defs as $key => [$lotNo, $material, $received, $available, $supplierLot, $container, $status, $daysAgo]) { + $lots[$key] = MaterialLot::create([ + 'lot_number' => $lotNo, + 'material_id' => $materials[$material]->id, + 'quantity_received' => $received, + 'quantity_available' => $available, + 'unit_of_measure' => $materials[$material]->unit_of_measure, + 'received_at' => now()->subDays($daysAgo), + 'status' => $status, + 'supplier_lot_no' => $supplierLot, + 'supplier_reference' => 'PO-'.(4400 + $daysAgo), + 'source_container_no' => $container, + 'hold_reason' => $status === MaterialLot::STATUS_QUARANTINE ? 'Wall thickness out of tolerance on inbound sample' : null, + 'held_at' => $status === MaterialLot::STATUS_QUARANTINE ? now()->subDays($daysAgo) : null, + 'created_by_id' => $this->operator->id, + ]); + } + + return $lots; + } + + /** Semi-finished: steel -> welded frames, output as a lot the bike orders consume. */ + private function seedFrameOrder(Line $line, array $stations, array $materials, array $lots): MaterialLot + { + $start = now()->subDays(12)->setTime(6, 10); + $order = $this->order('TR-FRAME-001', $line, 'TR-PT-FRAME', 'Welded bike frame', 60, 60, WorkOrder::STATUS_DONE, null, $start->copy()->addHours(7)); + $batch = $this->batch($order, 1, 'TR-FRM-L001', 60, 60, Batch::STATUS_DONE, $start, $start->copy()->addHours(7)); + + $cut = $this->step($batch, 1, 'Cut tubes', $stations['CUT'], $start, 95, 62, 2, 10, 1.5); + $weld = $this->step($batch, 2, 'Weld frame', $stations['WELD'], $start->copy()->addMinutes(100), 310, 60, 2, 15, 5); + + $this->consume($cut, $lots['steel1'], 186, $cut->started_at); + $this->consume($weld, $lots['steel1'], 12, $weld->started_at); + + return MaterialLot::create([ + 'lot_number' => 'TR-SEMI-FRAME-01', + 'material_id' => $materials['frame']->id, + 'quantity_received' => 60, + 'quantity_available' => 8, + 'unit_of_measure' => 'pcs', + 'received_at' => $batch->completed_at, + 'manufacturing_date' => Carbon::parse($batch->completed_at)->toDateString(), + 'status' => MaterialLot::STATUS_RELEASED, + 'source_batch_id' => $batch->id, + 'created_by_id' => $this->operator->id, + ]); + } + + /** Finished and shipped: frames + paint -> bikes, pallets, serials, QC, customer order. */ + private function seedFinishedBikeOrder(Line $line, array $stations, array $materials, array $lots, MaterialLot $frameLot): void + { + $start = now()->subDays(8)->setTime(6, 5); + $end = $start->copy()->addHours(6); + $order = $this->order('TR-BIKE-001', $line, 'TR-PT-BIKE', 'City bike', 30, 30, WorkOrder::STATUS_DONE, self::CUSTOMER_ORDER, $end); + $batch = $this->batch($order, 1, 'TR-FG-L001', 30, 30, Batch::STATUS_DONE, $start, $end); + + $paint = $this->step($batch, 1, 'Powder coat', $stations['PAINT'], $start, 80, 31, 1, 10, 2); + $assy = $this->step($batch, 2, 'Final assembly', $stations['ASSY'], $start->copy()->addMinutes(90), 260, 30, 1, 10, 5); + + $this->consume($paint, $frameLot, 32, $paint->started_at); + $this->consume($paint, $lots['paint1'], 9.6, $paint->started_at); + + MaterialLot::create([ + 'lot_number' => 'TR-FG-BIKE-01', + 'material_id' => $materials['bike']->id, + 'quantity_received' => 30, + 'quantity_available' => 0, + 'unit_of_measure' => 'pcs', + 'received_at' => $end, + 'status' => MaterialLot::STATUS_RELEASED, + 'source_batch_id' => $batch->id, + 'created_by_id' => $this->operator->id, + ]); + + $shipped = Pallet::create([ + 'work_order_id' => $order->id, 'batch_id' => $batch->id, 'qty' => 20, 'status' => 'shipped', + 'quality_status' => 'pass', 'location' => 'DOCK-2', 'destination' => 'Velo Nord GmbH, Hamburg', 'erp_reference' => 'TR-WZ-0871', + ]); + $shipped->forceFill(['shipped_at' => $end->copy()->addDays(2)])->saveQuietly(); + $closed = Pallet::create([ + 'work_order_id' => $order->id, 'batch_id' => $batch->id, 'qty' => 10, 'status' => 'closed', + 'quality_status' => 'pass', 'location' => 'FG-A-03-01', + ]); + + $check = QualityCheck::create([ + 'batch_id' => $batch->id, 'pallet_id' => $closed->id, 'checked_by' => $this->operator->id, + 'checked_at' => $end->copy()->subMinutes(20), 'production_quantity' => 30, 'all_passed' => true, + 'notes' => 'Final inspection before packing.', + ]); + foreach ([['Coating thickness [um]', 'numeric', 82.4, null], ['Brake test', 'boolean', null, true], ['Wheel runout [mm]', 'numeric', 0.6, null]] as $i => [$name, $type, $numeric, $bool]) { + QualityCheckSample::create([ + 'quality_check_id' => $check->id, 'sample_number' => $i + 1, 'parameter_name' => $name, + 'parameter_type' => $type, 'value_numeric' => $numeric, 'value_boolean' => $bool, 'is_passed' => true, + ]); + } + + // Serial units: most pass both stations, one needed rework, one was scrapped. + foreach (range(1, 6) as $n) { + $failed = $n === 5; + $unit = SerialUnit::create([ + 'serial_no' => sprintf('TR-SN-%04d', $n), + 'work_order_id' => $order->id, + 'batch_id' => $batch->id, + 'material_id' => $materials['bike']->id, + 'status' => $failed ? 'scrapped' : ($n <= 4 ? 'shipped' : 'completed'), + 'produced_at' => $failed ? null : $end, + ]); + $at = $paint->started_at->copy()->addMinutes(10 + $n * 3); + $this->history($unit, $paint, $stations['PAINT'], $at, 'pass', ['oven_temp_c' => 190 + $n, 'cure_min' => 12]); + if ($n === 3) { + $this->history($unit, $assy, $stations['ASSY'], $at->copy()->addMinutes(95), 'rework', ['torque_nm' => 31.2], 'Crank bolt under torque - retightened.'); + } + $this->history($unit, $assy, $stations['ASSY'], $at->copy()->addMinutes(110), $failed ? 'fail' : 'pass', ['torque_nm' => $failed ? 22.5 : 40.1], $failed ? 'Cracked weld found at head tube.' : null); + } + } + + /** + * Still running, the way Transfer flow looks mid-order: both stations open + * at once, pieces waiting between them, some scrap already logged. + */ + private function seedRunningBikeOrder(Line $line, array $stations, array $lots, MaterialLot $frameLot): void + { + $start = now()->subHours(5); + $order = $this->order('TR-BIKE-002', $line, 'TR-PT-BIKE', 'City bike', 20, 9, WorkOrder::STATUS_IN_PROGRESS, self::CUSTOMER_ORDER, null); + $batch = $this->batch($order, 1, 'TR-FG-L002', 20, 9, Batch::STATUS_IN_PROGRESS, $start, null); + + $paint = $this->step($batch, 1, 'Powder coat', $stations['PAINT'], $start, null, 16, 1, 10, 2); + $assy = $this->step($batch, 2, 'Final assembly', $stations['ASSY'], $start->copy()->addMinutes(45), null, 9, 0, 10, 5); + + $this->consume($paint, $frameLot, 20, $paint->started_at); + $this->consume($paint, $lots['paint1'], 5.4, $paint->started_at); + // A second steel lot enters here (replacement brackets), so this order's + // genealogy differs from TR-BIKE-001 although the product is the same. + $this->consume($assy, $lots['steel2'], 4, $assy->started_at); + + Pallet::create([ + 'work_order_id' => $order->id, 'batch_id' => $batch->id, 'qty' => 9, 'status' => 'open', + 'quality_status' => 'pending', 'location' => 'LINE-END', + ]); + } + + private function seedPendingBikeOrder(Line $line): void + { + $this->order('TR-BIKE-003', $line, 'TR-PT-BIKE', 'City bike', 40, 0, WorkOrder::STATUS_PENDING, 'TR-CO-5002', null); + } + + // ── builders ───────────────────────────────────────────────────────── + + private function order(string $no, Line $line, string $productCode, string $productName, int $planned, int $produced, string $status, ?string $customerOrder, ?Carbon $completedAt): WorkOrder + { + $product = ProductType::firstOrCreate(['code' => $productCode], ['name' => "TR - {$productName}", 'is_active' => true]); + + return WorkOrder::create([ + 'order_no' => $no, + 'line_id' => $line->id, + 'product_type_id' => $product->id, + 'customer_order_no' => $customerOrder, + 'planned_qty' => $planned, + 'produced_qty' => $produced, + 'status' => $status, + 'priority' => 3, + 'due_date' => now()->addDays(10)->toDateString(), + 'completed_at' => $completedAt, + 'description' => 'Traceability demo', + ]); + } + + private function batch(WorkOrder $order, int $number, string $lot, int $target, int $produced, string $status, Carbon $startedAt, ?Carbon $completedAt): Batch + { + return Batch::create([ + 'work_order_id' => $order->id, + 'batch_number' => $number, + 'lot_number' => $lot, + 'target_qty' => $target, + 'produced_qty' => $produced, + 'status' => $status, + 'started_at' => $startedAt, + 'completed_at' => $completedAt, + ]); + } + + /** $minutes null = still running. Times and quantities are what a station timeline will plot. */ + private function step(Batch $batch, int $number, string $name, Workstation $station, Carbon $startedAt, ?int $minutes, float $passed, float $scrap, int $setupMinutes, float $runPerUnit): BatchStep + { + $done = $minutes !== null; + + return BatchStep::create([ + 'batch_id' => $batch->id, + 'step_number' => $number, + 'name' => $name, + 'status' => $done ? BatchStep::STATUS_DONE : BatchStep::STATUS_IN_PROGRESS, + 'workstation_id' => $station->id, + 'started_at' => $startedAt, + 'started_by_id' => $this->operator->id, + 'completed_at' => $done ? $startedAt->copy()->addMinutes($minutes) : null, + 'completed_by_id' => $done ? $this->operator->id : null, + 'duration_minutes' => $minutes, + 'setup_time_minutes' => $setupMinutes, + 'run_time_per_unit_minutes' => $runPerUnit, + 'passed_qty' => $passed, + 'scrap_qty' => $scrap, + ]); + } + + private function consume(BatchStep $step, MaterialLot $lot, float $qty, $at): void + { + BatchStepLotConsumption::create([ + 'batch_step_id' => $step->id, + 'material_lot_id' => $lot->id, + 'quantity_consumed' => $qty, + 'consumed_at' => $at, + 'recorded_by_id' => $this->operator->id, + ]); + } + + private function history(SerialUnit $unit, BatchStep $step, Workstation $station, Carbon $at, string $result, array $parameters, ?string $notes = null): void + { + UnitStepHistory::create([ + 'serial_unit_id' => $unit->id, + 'batch_step_id' => $step->id, + 'workstation_id' => $station->id, + 'operator_id' => $this->operator->id, + 'parameters' => $parameters, + 'result' => $result, + 'notes' => $notes, + 'processed_at' => $at, + ]); + } +} diff --git a/backend/lang/en.json b/backend/lang/en.json index c5abdbdaf..c6eca6014 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -6356,5 +6356,10 @@ "Packed / Planned": "Packed / Planned", "Filter by step": "Filter by step", "All steps": "All steps", - "No BOM components assigned to this step.": "No BOM components assigned to this step." + "No BOM components assigned to this step.": "No BOM components assigned to this step.", + "Back to the list": "Back to the list", + "Identifier to trace": "Identifier to trace", + "Exact number: order, LOT, pallet, serial, supplier LOT, container…": "Exact number: order, LOT, pallet, serial, supplier LOT, container…", + "What to trace": "What to trace", + "Nothing traceable matches": "Nothing traceable matches" } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 4ccfe4c64..941cde480 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -6363,5 +6363,27 @@ "Packed / Planned": "Spakowano / Plan", "Filter by step": "Filtruj według kroku", "All steps": "Wszystkie kroki", - "No BOM components assigned to this step.": "Do tego kroku nie przypisano komponentów BOM." + "No BOM components assigned to this step.": "Do tego kroku nie przypisano komponentów BOM.", + "Stock receipt": "Przyjęcie magazynowe", + "Stock return": "Zwrot do magazynu", + "Material allocation": "Przydział materiału", + "Consumption": "Zużycie", + "Stock adjustment": "Korekta stanu", + "Stock transfer": "Przesunięcie magazynowe", + "Reclassification": "Przeklasyfikowanie", + "Manual adjustment": "Korekta ręczna", + "Stock document": "Dokument magazynowy", + "ERP sync": "Synchronizacja ERP", + "Allocated to batch #:batch": "Przydzielono do partii #:batch", + "Allocated to batch #:batch (step :step)": "Przydzielono do partii #:batch (krok :step)", + "Batch #:batch completed — leftover returned to stock": "Partia #:batch zakończona — pozostałość zwrócono do magazynu", + "Batch #:batch scrap qty recorded": "Zarejestrowano odpady partii #:batch", + "Batch #:batch cancelled — return to stock": "Partia #:batch anulowana — zwrot do magazynu", + "Adjustment on batch #:batch": "Korekta partii #:batch", + "Batch #:batch — unused material returned to stock": "Partia #:batch — niewykorzystany materiał zwrócono do magazynu", + "Back to the list": "Wróć do listy", + "Identifier to trace": "Identyfikator do prześledzenia", + "Exact number: order, LOT, pallet, serial, supplier LOT, container…": "Dokładny numer: zlecenie, LOT, paleta, numer seryjny, LOT dostawcy, kontener…", + "What to trace": "Co prześledzić", + "Nothing traceable matches": "Brak pasującego obiektu do prześledzenia dla" } diff --git a/backend/resources/js/Pages/admin/traceability/Index.jsx b/backend/resources/js/Pages/admin/traceability/Index.jsx index 9acb512f0..53aa2af26 100644 --- a/backend/resources/js/Pages/admin/traceability/Index.jsx +++ b/backend/resources/js/Pages/admin/traceability/Index.jsx @@ -1,75 +1,232 @@ import { useState } from 'react'; import { Head, Link, router, usePage } from '@inertiajs/react'; -import { Button } from '@openmes/ui'; +import { Breadcrumbs, Button, Icon, Tabs, TextField } from '@openmes/ui'; import { DataTable } from '@openmes/ui/table'; import AppLayout from '../../../layouts/AppLayout'; +import PageTitle from '../../../components/PageTitle'; +import ResourceTable from '../../../components/ResourceTable'; +import { woColumns } from '../work-orders/columns'; +import { STATUS_STYLES as LOT_STATUS_STYLES, materialLotStatusLabel } from '../material-lots/fields'; import { __, formatNumber } from '../../../lib/i18n'; /** - * Traceability / genealogy console. Resolves a finished LOT, material lot, - * supplier LOT or serial number (server-side) and renders its genealogy. - * Props: { term, result } — result.type is 'batch' | 'material_lot' | 'serial'. + * Traceability / genealogy console. + * + * With nothing traced it is a place to find the thing: work orders, material + * lots and pallets as searchable tables whose rows open their trace, so nobody + * has to copy a number out of another screen first. The identifier box stays for + * what no table lists - a scanned label, a serial number, a supplier LOT, a + * customer order - and resolves server-side. + * + * Props: { term, result, lineNames, productTypeNames, customerNames, + * materialNames, workOrderNumbers } - result.type is 'work_order' | + * 'customer_order' | 'pallet' | 'batch' | 'material_lot' | 'serial'. */ +const RESULT_TAB = { + work_order: 'orders', + customer_order: 'orders', + batch: 'lots', + material_lot: 'lots', + serial: 'orders', + pallet: 'pallets', +}; + export default function TraceabilityIndex() { const { term = '', result = null } = usePage().props; const [q, setQ] = useState(term); + const [tab, setTab] = useState(() => { + const param = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('tab') : null; + return ['orders', 'lots', 'pallets'].includes(param) ? param : 'orders'; + }); const submit = (e) => { e.preventDefault(); - router.get('/admin/traceability', { q }, { preserveState: true, preserveScroll: true }); + if (q.trim() === '') return; + router.get('/admin/traceability', { q: q.trim() }); }; + const tracing = term !== ''; + return ( <> -
-
-

{__('Traceability')}

-

- {__('Trace a pallet number, customer order, finished LOT, material lot, supplier LOT, source container or serial number through its full genealogy.')} -

-
- - {/* Search */} -
-
{__('Search')}
-
- setQ(e.target.value)} - autoFocus - placeholder={__('Pallet no, customer order, finished LOT, material lot, supplier LOT, source container or serial number…')} - className="form-input flex-1" - /> - -
+
+ + {tracing && ( + + + {__('Back to the list')} + + )} + + - {term !== '' && !result && ( -
- - - -

- {__('No finished LOT, material lot or serial number matches')} {term}. -

-
+ {!tracing && ( + )} - - {result?.type === 'pallet' && } - {result?.type === 'customer_order' && } - {result?.type === 'batch' && } - {result?.type === 'material_lot' && } - {result?.type === 'serial' && }
+ + {!tracing && } + + {tracing && ( +
+ {/* The browse tables put this trail in the header; a result has no table to do it. */} + + + + + {!result && ( +
+ +

+ {__('Nothing traceable matches')} {term}. +

+
+ )} + + {result?.type === 'pallet' && } + {result?.type === 'work_order' && } + {result?.type === 'customer_order' && } + {result?.type === 'batch' && } + {result?.type === 'material_lot' && } + {result?.type === 'serial' && } +
+ )} ); } +/* ── Browse: find the thing to trace ─────────────────────────────────── */ + +const WO_BROWSE_COLUMNS = ['order_no', 'customer', 'line', 'product', 'qty', 'status', 'due_date', 'completed_at', 'customer_order_no', 'created_at']; + +const PALLET_STATUS_STYLES = { + open: 'bg-om-running-bg text-om-running', + closed: 'bg-om-chip text-om-accent', + shipped: 'bg-om-chip text-om-muted', +}; + +/** + * One table per kind of traceable thing. Each is the same synced collection its + * own admin list shows, so it is live and needs no endpoint of its own; the row's + * identifier and its Trace action both open the trace. + */ +function BrowseTable({ tab }) { + const { + lineNames = {}, productTypeNames = {}, customerNames = {}, materialNames = {}, workOrderNumbers = {}, + } = usePage().props; + + const traceAction = (identifier) => (r) => [{ label: 'Trace', icon: 'open', href: traceLink(identifier(r)) }]; + + if (tab === 'lots') { + return ( + traceLink(r.lot_number)} + columns={[ + { key: 'lot_number', label: __('Lot Number'), className: 'font-mono', filter: 'text', link: true }, + { key: 'material', label: __('Material'), className: 'text-om-muted', value: (r) => materialNames[r.material_id] ?? '', render: (r) => materialNames[r.material_id] ?? '—' }, + { key: 'supplier_lot_no', label: __('Supplier LOT'), className: 'font-mono text-om-muted', render: (r) => r.supplier_lot_no || '—' }, + { key: 'source_container_no', label: __('Source container'), className: 'font-mono text-om-muted', render: (r) => r.source_container_no || '—' }, + { key: 'received_at', label: __('Received'), filter: 'date', className: 'text-om-muted', render: (r) => (r.received_at ? String(r.received_at).slice(0, 10) : '—') }, + { + key: 'status', + label: __('Status'), + value: (r) => r.status, + render: (r) => ( + + {materialLotStatusLabel(r.status)} + + ), + }, + ]} + orderBy="received_at" + orderDir="desc" + actions={traceAction((r) => r.lot_number)} + enableSelection={false} + emptyText="No material lots yet." + /> + ); + } + + if (tab === 'pallets') { + return ( + traceLink(r.pallet_no)} + columns={[ + { key: 'pallet_no', label: __('Pallet number'), className: 'font-mono', filter: 'text', link: true }, + { key: 'work_order', label: __('Work Order'), className: 'font-mono text-om-muted', value: (r) => workOrderNumbers[r.work_order_id] ?? '', render: (r) => workOrderNumbers[r.work_order_id] ?? '—' }, + { key: 'qty', label: __('Quantity') }, + { + key: 'status', + label: __('Status'), + value: (r) => r.status, + render: (r) => ( + {r.status} + ), + }, + { key: 'destination', label: __('Destination'), className: 'text-om-muted', render: (r) => r.destination || '—' }, + { key: 'created_at', label: __('Created'), filter: 'date', className: 'text-om-muted', render: (r) => (r.created_at ? String(r.created_at).slice(0, 10) : '—') }, + ]} + orderBy="pallet_no" + orderDir="desc" + actions={traceAction((r) => r.pallet_no)} + enableSelection={false} + emptyText="No pallets yet." + /> + ); + } + + // The work orders list's own column definitions, narrowed to what helps pick + // an order out - so status pills, quantities and dates read the same here. + const columns = woColumns({ lineNames, productTypeNames, customerNames, detailHref: (r) => traceLink(r.order_no) }) + .filter((c) => WO_BROWSE_COLUMNS.includes(c.key)); + + return ( + traceLink(r.order_no)} + columns={columns} + orderBy="created_at" + orderDir="desc" + actions={traceAction((r) => r.order_no)} + enableSelection={false} + emptyText="No work orders yet." + /> + ); +} + TraceabilityIndex.layout = (page) => {page}; /* ── helpers ─────────────────────────────────────────────────────────── */ @@ -475,9 +632,18 @@ function CustomerOrderResult({ data }) { return (
- {__('Customer order')} -

{data.customer_order_no}

-

{wos.length} {__('work orders')}

+ {/* One work order (traced by its own number) or every order of a customer order. */} + {data.order_no ? __('Work Order') : __('Customer order')} +

{data.order_no ?? data.customer_order_no}

+ {data.order_no ? ( + data.customer_order_no && ( +

+ {__('Customer Order No')}: {data.customer_order_no} +

+ ) + ) : ( +

{wos.length} {__('work orders')}

+ )}
{wos.length === 0 ? ( diff --git a/backend/tests/Feature/Seeders/TraceabilityDemoSeederTest.php b/backend/tests/Feature/Seeders/TraceabilityDemoSeederTest.php new file mode 100644 index 000000000..e1a4c0f5b --- /dev/null +++ b/backend/tests/Feature/Seeders/TraceabilityDemoSeederTest.php @@ -0,0 +1,56 @@ +create(); + + $this->seed(TraceabilityDemoSeeder::class); + + $tracer = app(TraceabilityService::class); + $steel = MaterialLot::where('lot_number', 'TR-RAW-STEEL-01')->firstOrFail(); + + // Steel -> frames -> bikes: the recall walk crosses the semi-finished lot. + $affected = collect($tracer->recallImpact(collect([$steel]))['work_orders'])->pluck('order_no'); + $this->assertContains('TR-FRAME-001', $affected); + $this->assertContains('TR-BIKE-001', $affected); + $this->assertContains('TR-BIKE-002', $affected); + + $this->assertSame('pallet', $tracer->resolve(WorkOrder::where('order_no', 'TR-BIKE-001')->firstOrFail()->pallets()->firstOrFail()->pallet_no)['type']); + $this->assertSame('batch', $tracer->resolve('TR-FG-L001')['type']); + $this->assertSame(6, SerialUnit::where('serial_no', 'like', 'TR-SN-%')->count()); + } + + public function test_running_it_twice_adds_nothing(): void + { + User::factory()->create(); + + $this->seed(TraceabilityDemoSeeder::class); + $this->seed(TraceabilityDemoSeeder::class); + + $this->assertSame(4, WorkOrder::where('order_no', 'like', 'TR-%')->count()); + $this->assertSame(1, MaterialLot::where('lot_number', 'TR-RAW-STEEL-01')->count()); + } + + public function test_it_is_not_part_of_the_default_seed(): void + { + $this->assertStringNotContainsString( + 'TraceabilityDemoSeeder', + file_get_contents(database_path('seeders/DatabaseSeeder.php')), + ); + } +} diff --git a/backend/tests/Feature/TraceabilityTest.php b/backend/tests/Feature/TraceabilityTest.php index 3b3e4f9a4..c995fcc3a 100644 --- a/backend/tests/Feature/TraceabilityTest.php +++ b/backend/tests/Feature/TraceabilityTest.php @@ -381,6 +381,53 @@ public function test_admin_can_trace_finished_lot(): void ->assertSee('RAW-1'); } + public function test_guest_is_redirected_from_traceability_page(): void + { + $this->get(route('admin.traceability.index'))->assertRedirect(route('login')); + } + + public function test_console_without_a_term_offers_the_browse_tables(): void + { + ['wo' => $wo] = $this->scenario(); + + $this->actingAs($this->admin) + ->get(route('admin.traceability.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('admin/traceability/Index') + ->where('result', null) + ->where("workOrderNumbers.{$wo->id}", 'WO-TRACE-1') + ->has('lineNames') + ->has('productTypeNames') + ->has('customerNames') + ->has('materialNames')); + } + + public function test_admin_can_trace_a_work_order_by_its_order_number(): void + { + $this->scenario(); + + $this->actingAs($this->admin) + ->get(route('admin.traceability.index', ['q' => 'WO-TRACE-1'])) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('result.type', 'work_order') + ->where('result.data.order_no', 'WO-TRACE-1') + ->has('result.data.work_orders', 1) + ->where('result.data.work_orders.0.batches.0.lot_number', 'FG-1') + ->where('result.data.work_orders.0.batches.0.components.0.lot_number', 'RAW-1')); + } + + public function test_work_order_trace_covers_only_the_order_asked_for(): void + { + $this->scenario(); + WorkOrder::factory()->create(['order_no' => 'WO-OTHER']); + + $trace = app(TraceabilityService::class)->workOrderTrace(WorkOrder::where('order_no', 'WO-TRACE-1')->firstOrFail()); + + $this->assertSame(['WO-TRACE-1'], $trace['work_orders']->pluck('order_no')->all()); + } + public function test_unknown_search_term_shows_no_result(): void { $this->actingAs($this->admin) From 1049d495f1815126bbda4a5c91aaff0fd5028c98 Mon Sep 17 00:00:00 2001 From: Mateusz Luczyski Date: Thu, 24 Sep 2026 11:44:00 +0200 Subject: [PATCH 02/21] feat: unit labels, station --- .../app/Console/Commands/ImportLineBundle.php | 176 +++++++++++++ .../Commands/ImportTestStationJsonl.php | 218 ++++++++++++++++ .../Api/V1/SerialUnitController.php | 5 +- .../Web/Admin/TraceabilityController.php | 5 +- .../Operator/UnitLabelStationController.php | 122 +++++++++ .../Web/Packaging/LabelPrintController.php | 41 +++ .../Web/Packaging/PackagingController.php | 38 +++ backend/app/Models/LabelTemplate.php | 18 ++ backend/app/Models/SerialUnit.php | 1 + .../app/Services/Lot/LotPatternFormatter.php | 7 +- .../app/Services/Packaging/LabelGenerator.php | 53 ++++ .../Traceability/SerialTraceService.php | 1 + ...3_100000_add_psn_to_serial_units_table.php | 30 +++ .../seeders/TraceabilityDemoSeeder.php | 1 + .../js/Pages/admin/traceability/Index.jsx | 5 + .../js/Pages/operator/unit-labels/Station.jsx | 235 ++++++++++++++++++ .../resources/js/Pages/packaging/Station.jsx | 62 +++++ .../js/components/LabelPrintMenu.jsx | 1 + .../packaging/pdf/labels/_label.blade.php | 6 + .../pdf/labels/serial-unit.blade.php | 14 ++ backend/routes/web.php | 7 + 21 files changed, 1042 insertions(+), 4 deletions(-) create mode 100644 backend/app/Console/Commands/ImportLineBundle.php create mode 100644 backend/app/Console/Commands/ImportTestStationJsonl.php create mode 100644 backend/app/Http/Controllers/Web/Operator/UnitLabelStationController.php create mode 100644 backend/database/migrations/2026_09_23_100000_add_psn_to_serial_units_table.php create mode 100644 backend/resources/js/Pages/operator/unit-labels/Station.jsx create mode 100644 backend/resources/views/packaging/pdf/labels/serial-unit.blade.php diff --git a/backend/app/Console/Commands/ImportLineBundle.php b/backend/app/Console/Commands/ImportLineBundle.php new file mode 100644 index 000000000..7258973b4 --- /dev/null +++ b/backend/app/Console/Commands/ImportLineBundle.php @@ -0,0 +1,176 @@ + [model, upsert key fields, ref field => source bundle key]. + * Order matters: referenced entities are processed before their referrers. + */ + private const ENTITIES = [ + 'lines' => [Line::class, ['code'], []], + 'workstations' => [Workstation::class, ['code'], ['line_id' => 'lines']], + 'product_types' => [ProductType::class, ['code'], []], + 'process_templates' => [ProcessTemplate::class, ['name'], ['product_type_id' => 'product_types']], + 'template_steps' => [TemplateStep::class, ['process_template_id', 'step_number'], ['process_template_id' => 'process_templates', 'workstation_id' => 'workstations']], + 'material_types' => [MaterialType::class, ['code'], []], + 'materials' => [Material::class, ['code'], ['material_type_id' => 'material_types']], + 'bom_items' => [BomItem::class, ['process_template_id', 'template_step_id', 'material_id'], ['process_template_id' => 'process_templates', 'template_step_id' => 'template_steps', 'material_id' => 'materials']], + 'issue_types' => [IssueType::class, ['code'], []], + 'lot_sequences' => [LotSequence::class, ['name'], ['product_type_id' => 'product_types']], + 'label_templates' => [LabelTemplate::class, ['name'], []], + 'work_orders' => [WorkOrder::class, ['order_no'], ['line_id' => 'lines', 'product_type_id' => 'product_types']], + ]; + + private const SKIPPED_KEYS = [ + 'sites' => 'optional module (table/model not installed)', + 'areas' => 'optional module (table/model not installed)', + ]; + + public function handle(WorkOrderService $workOrders): int + { + $totalCreated = 0; + $totalUpdated = 0; + $errors = 0; + + foreach ((array) $this->argument('paths') as $path) { + if (!is_file($path)) { + $this->error("File not found: {$path}"); + $errors++; + continue; + } + + $file = json_decode((string) file_get_contents($path), true); + if (!is_array($file)) { + $this->error("Invalid JSON: {$path}"); + $errors++; + continue; + } + + $this->info("Importing {$path}"); + $posMap = []; + + foreach (self::ENTITIES as $key => [$modelClass, $keyFields, $refs]) { + if (!isset($file[$key]) || !is_array($file[$key]) || $file[$key] === []) { + continue; + } + [$created, $updated, $failed] = $this->upsertEntity($key, $modelClass, $keyFields, $refs, $file[$key], $file, $posMap, $workOrders); + $totalCreated += $created; + $totalUpdated += $updated; + $errors += $failed; + $this->line(sprintf(' %-18s %d created, %d updated, %d failed', $key, $created, $updated, $failed)); + } + + foreach (self::SKIPPED_KEYS as $key => $reason) { + if (isset($file[$key])) { + $this->warn(" {$key} skipped: {$reason}"); + } + } + } + + $this->info("Done: {$totalCreated} created, {$totalUpdated} updated, {$errors} errors."); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * @return array{0:int,1:int,2:int} created, updated, failed + */ + private function upsertEntity(string $key, string $modelClass, array $keyFields, array $refs, array $rows, array $file, array &$posMap, WorkOrderService $workOrders): array + { + $probe = new $modelClass(); + if (!Schema::hasTable($probe->getTable())) { + $this->warn(" {$key} skipped: table {$probe->getTable()} does not exist"); + + return [0, 0, 0]; + } + + $fillable = array_flip($probe->getFillable()); + $posMap[$key] = []; + $created = 0; + $updated = 0; + $failed = 0; + + foreach ($rows as $i => $row) { + $pos = $i + 1; + try { + foreach ($refs as $field => $srcKey) { + if (array_key_exists($field, $row)) { + $row[$field] = $this->resolveRef($row[$field], $srcKey, $file, $posMap, $key, $pos, $field); + } + } + + if ($key === 'work_orders' + && (($row['process_snapshot'] ?? null) === 'auto' || !array_key_exists('process_snapshot', $row))) { + $row['process_snapshot'] = $workOrders->buildProcessSnapshot($row['product_type_id'] ?? null) ?? ['steps' => []]; + } + + $attrs = array_intersect_key($row, $fillable); + $keyValues = array_intersect_key($attrs, array_flip($keyFields)); + + $model = $modelClass::query()->where($keyValues)->first(); + if ($model !== null) { + $model->update(array_diff_key($attrs, $keyValues)); + $updated++; + } else { + $model = $modelClass::create($attrs); + $created++; + } + $posMap[$key][$pos] = $model; + } catch (Throwable $e) { + $failed++; + $label = $row['code'] ?? $row['name'] ?? $row['order_no'] ?? ''; + $this->error(" {$key}[{$pos}] {$label}: {$e->getMessage()}"); + } + } + + return [$created, $updated, $failed]; + } + + /** + * Resolve a bundle reference (1-based position in the referenced array of + * this file) to the imported model's id. + */ + private function resolveRef(mixed $value, string $srcKey, array $file, array $posMap, string $ownerKey, int $pos, string $field): ?int + { + $srcPos = (int) $value; + $src = $file[$srcKey] ?? null; + if (!is_array($src) || !isset($src[$srcPos - 1])) { + $this->warn(" {$ownerKey}[{$pos}].{$field}: position {$srcPos} of {$srcKey} is not defined in this file, leaving null"); + + return null; + } + + return $posMap[$srcKey][$srcPos]->id ?? null; + } +} diff --git a/backend/app/Console/Commands/ImportTestStationJsonl.php b/backend/app/Console/Commands/ImportTestStationJsonl.php new file mode 100644 index 000000000..f49e62fdd --- /dev/null +++ b/backend/app/Console/Commands/ImportTestStationJsonl.php @@ -0,0 +1,218 @@ +collectFiles(); + if ($files === []) { + $this->error('No .jsonl files found for the given paths.'); + + return self::FAILURE; + } + + $workOrder = $this->resolveWorkOrder(); + $operator = $this->resolveOperator(); + if ($operator === null) { + $this->error('No user found for the operator. Create a user first.'); + + return self::FAILURE; + } + + $imported = 0; + $skipped = 0; + $errors = 0; + + foreach ($files as $file) { + try { + $lines = array_values(array_filter( + array_map('trim', File::lines($file)->all()), + fn ($l) => $l !== '' + )); + + $cycle = null; + $steps = []; + $verdict = null; + foreach ($lines as $line) { + $row = json_decode($line, true); + if (!is_array($row)) { + continue; + } + $t = $row['t'] ?? null; + if ($t === 'cycle') { + $cycle = $row; + } elseif ($t === 's') { + $steps[] = $row; + } elseif ($t === 'v') { + $verdict = $row; + } + } + + if ($cycle === null || empty($cycle['sn'])) { + $this->warn("[SKIP] {$file}: no cycle line with sn"); + $skipped++; + continue; + } + + $sn = trim((string) $cycle['sn']); + $cycleId = (string) ($cycle['id'] ?? ''); + $psn = isset($cycle['psn']) && $cycle['psn'] !== '' ? trim((string) $cycle['psn']) : null; + $stationCode = $this->option('workstation') ?: ($cycle['station'] ?? null); + + $unit = SerialUnit::where('serial_no', $sn)->first(); + if ($unit === null) { + $unit = $serials->registerUnit($sn, [ + 'psn' => $psn, + 'work_order_id' => $workOrder?->id, + 'status' => SerialUnit::STATUS_IN_PRODUCTION, + ]); + } else { + $update = []; + if ($psn !== null && empty($unit->psn)) { + $update['psn'] = $psn; + } + if ($workOrder !== null && empty($unit->work_order_id)) { + $update['work_order_id'] = $workOrder->id; + } + if ($update !== []) { + $unit->update($update); + } + } + + // Idempotency: skip when this exact test cycle was already imported. + $alreadyImported = $cycleId !== '' && $unit->history() + ->get() + ->contains(fn ($h) => ($h->parameters['cycle_id'] ?? null) === $cycleId); + + if ($alreadyImported) { + $this->line("[SKIP] {$file}: cycle {$cycleId} already imported"); + $skipped++; + continue; + } + + $workstation = $stationCode + ? Workstation::where('code', $stationCode)->first() + : null; + + $failedSteps = array_values(array_map( + fn ($s) => $s['n'] ?? null, + array_filter($steps, fn ($s) => ($s['r'] ?? 'P') === 'F') + )); + + $verdictR = $verdict['r'] ?? 'P'; + $result = $verdictR === 'F' ? 'fail' : ($verdictR === 'R' ? 'rework' : 'pass'); + + $serials->recordStep($unit, $operator, null, [ + 'workstation_id' => $workstation?->id, + 'parameters' => [ + 'cycle_id' => $cycleId, + 'file' => basename($file), + 'station' => $cycle['station'] ?? null, + 'line' => $cycle['line'] ?? null, + 'operator' => $cycle['op'] ?? null, + 'software' => $cycle['sw'] ?? null, + 'limits' => $cycle['lim'] ?? null, + 'sfr' => $cycle['sfr'] ?? null, + 'dut' => $cycle['dut'] ?? null, + 'start' => $cycle['start'] ?? null, + 'steps' => $steps, + 'failed_steps' => $failedSteps, + ], + 'result' => $result, + 'notes' => $result === 'fail' + ? 'Test station verdict FAIL, steps: ' . implode(', ', $failedSteps) + : "Test station verdict {$verdictR}", + ]); + + $this->line(sprintf( + '[OK] %s: unit %s (station %s) verdict %s%s', + $file, + $sn, + $stationCode ?? 'n/a', + strtoupper($result), + $failedSteps !== [] ? ', failed steps: ' . implode(', ', $failedSteps) : '' + )); + $imported++; + } catch (Throwable $e) { + $this->error("[ERROR] {$file}: {$e->getMessage()}"); + $errors++; + } + } + + $this->info("Done: {$imported} imported, {$skipped} skipped, {$errors} errors."); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * @return string[] + */ + private function collectFiles(): array + { + $files = []; + foreach ((array) $this->argument('paths') as $path) { + if (is_dir($path)) { + $found = glob($path . '/*.jsonl') ?: []; + sort($found); + $files = array_merge($files, $found); + } elseif (is_file($path)) { + $files[] = $path; + } else { + $this->warn("Path not found: {$path}"); + } + } + + return array_values(array_unique($files)); + } + + private function resolveWorkOrder(): ?WorkOrder + { + $value = $this->option('work-order'); + if ($value === null || $value === '') { + return null; + } + + $workOrder = ctype_digit((string) $value) + ? WorkOrder::whereKey($value)->first() + : WorkOrder::where('order_no', $value)->first(); + + if ($workOrder === null) { + $this->warn("Work order not found: {$value} (continuing without work order)"); + } + + return $workOrder; + } + + private function resolveOperator(): ?User + { + $value = $this->option('operator'); + if ($value !== null && $value !== '') { + $user = User::where('email', $value)->orWhere('name', $value)->first(); + if ($user !== null) { + return $user; + } + $this->warn("Operator not found: {$value} (falling back to first user)"); + } + + return User::orderBy('id')->first(); + } +} diff --git a/backend/app/Http/Controllers/Api/V1/SerialUnitController.php b/backend/app/Http/Controllers/Api/V1/SerialUnitController.php index 8d09230ef..f4c2ff91d 100644 --- a/backend/app/Http/Controllers/Api/V1/SerialUnitController.php +++ b/backend/app/Http/Controllers/Api/V1/SerialUnitController.php @@ -23,7 +23,9 @@ public function index(Request $request): JsonResponse $units = SerialUnit::query() ->when($request->query('work_order_id'), fn ($q, $id) => $q->where('work_order_id', $id)) ->when($request->query('status'), fn ($q, $s) => $q->where('status', $s)) - ->when($request->query('search'), fn ($q, $s) => $q->where('serial_no', 'like', "%{$s}%")) + ->when($request->query('search'), fn ($q, $s) => $q->where( + fn ($qq) => $qq->where('serial_no', 'like', "%{$s}%")->orWhere('psn', 'like', "%{$s}%") + )) ->orderByDesc('id') ->limit(100) ->get(); @@ -40,6 +42,7 @@ public function store(Request $request): JsonResponse { $data = $request->validate([ 'serial_no' => ['required', 'string', 'max:100'], + 'psn' => ['nullable', 'string', 'max:100'], 'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'], 'batch_id' => ['nullable', 'integer', 'exists:batches,id'], 'material_id' => ['nullable', 'integer', 'exists:materials,id'], diff --git a/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php b/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php index 695b18d9e..beac2221e 100644 --- a/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php +++ b/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php @@ -56,7 +56,9 @@ public function index(Request $request) 'forward' => $this->mapForward($this->tracer->forwardTrace($lot)), 'backward' => $this->tracer->backwardTraceLot($lot), ]; - } elseif ($unit = SerialUnit::where('serial_no', $term)->first()) { + } elseif ($unit = SerialUnit::where(function ($q) use ($term) { + $q->where('serial_no', $term)->orWhere('psn', $term); + })->first()) { $result = [ 'type' => 'serial', 'recall' => $this->tracer->recallImpactForSerial($unit), @@ -160,6 +162,7 @@ private function mapSerial(SerialUnit $u): array { return [ 'serial_no' => $u->serial_no, + 'psn' => $u->psn, 'status' => $u->status, 'product' => $u->workOrder?->productType?->name ?? $u->material?->name, 'work_order' => $u->workOrder?->order_no, diff --git a/backend/app/Http/Controllers/Web/Operator/UnitLabelStationController.php b/backend/app/Http/Controllers/Web/Operator/UnitLabelStationController.php new file mode 100644 index 000000000..8ba8e2af9 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Operator/UnitLabelStationController.php @@ -0,0 +1,122 @@ +with('productType:id,name') + ->orderByDesc('id') + ->limit(100) + ->get() + ->map(fn (WorkOrder $wo) => [ + 'id' => $wo->id, + 'order_no' => $wo->order_no, + 'product' => $wo->productType?->name ?? '', + 'status' => $wo->status, + ]); + + return Inertia::render('operator/unit-labels/Station', [ + 'workOrders' => $workOrders, + ]); + } + + /** + * Bind a scanned serial number to its process serial number. Creates the + * unit on first sight, updates on re-scan. + */ + public function apply(Request $request): JsonResponse + { + $data = $request->validate([ + 'serial_no' => ['required', 'string', 'max:100'], + 'psn' => ['nullable', 'string', 'max:100'], + 'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'], + ]); + + $serialNo = trim((string) $data['serial_no']); + $psn = isset($data['psn']) && trim((string) $data['psn']) !== '' ? trim((string) $data['psn']) : null; + $workOrderId = $data['work_order_id'] ?? null; + + $unit = SerialUnit::where('serial_no', $serialNo)->first(); + + if ($unit !== null) { + $changed = false; + if ($psn !== null && $unit->psn !== $psn) { + $unit->psn = $psn; + $changed = true; + } + if ($workOrderId !== null && empty($unit->work_order_id)) { + $unit->work_order_id = $workOrderId; + $changed = true; + } + if ($changed) { + $unit->save(); + } + $created = false; + $message = 'Unit already registered, label binding confirmed.'; + } else { + $unit = $this->serials->registerUnit($serialNo, [ + 'psn' => $psn, + 'work_order_id' => $workOrderId, + 'status' => SerialUnit::STATUS_IN_PRODUCTION, + ]); + $created = true; + $message = 'Unit registered, PSN bound to serial number.'; + } + + return response()->json([ + 'created' => $created, + 'unit' => $this->payload($unit), + 'message' => $message, + ]); + } + + /** + * Recent units for the station screen, optionally filtered by work order. + */ + public function units(Request $request): JsonResponse + { + $workOrderId = $request->integer('work_order_id') ?: null; + + $units = SerialUnit::query() + ->when($workOrderId, fn ($q) => $q->where('work_order_id', $workOrderId)) + ->with('workOrder:id,order_no') + ->orderByDesc('id') + ->limit(50) + ->get() + ->map(fn (SerialUnit $u) => $this->payload($u)); + + return response()->json(['units' => $units]); + } + + private function payload(SerialUnit $unit): array + { + return [ + 'id' => $unit->id, + 'serial_no' => $unit->serial_no, + 'psn' => $unit->psn, + 'status' => $unit->status, + 'work_order' => $unit->workOrder?->order_no, + 'produced_at' => $unit->produced_at?->toIso8601String(), + ]; + } +} diff --git a/backend/app/Http/Controllers/Web/Packaging/LabelPrintController.php b/backend/app/Http/Controllers/Web/Packaging/LabelPrintController.php index 80f17a515..4111dabbc 100644 --- a/backend/app/Http/Controllers/Web/Packaging/LabelPrintController.php +++ b/backend/app/Http/Controllers/Web/Packaging/LabelPrintController.php @@ -8,6 +8,7 @@ use App\Models\BatchStep; use App\Models\LabelTemplate; use App\Models\Pallet; +use App\Models\SerialUnit; use App\Models\WorkOrder; use App\Services\Packaging\LabelGenerator; use Illuminate\Http\Request; @@ -94,6 +95,25 @@ public function palletZpl(Request $request, Pallet $pallet) ]); } + public function serialUnitPdf(Request $request, SerialUnit $serialUnit) + { + $template = $this->resolveTemplate($request, LabelTemplate::TYPE_SERIAL_UNIT); + $pdf = $this->generator->pdfForSerialUnits(collect([$serialUnit]), $template); + + return $pdf->stream('label-unit-'.$this->unitFileName($serialUnit).'.pdf'); + } + + public function serialUnitZpl(Request $request, SerialUnit $serialUnit) + { + $template = $this->resolveTemplate($request, LabelTemplate::TYPE_SERIAL_UNIT); + $zpl = $this->generator->zplForSerialUnits(collect([$serialUnit]), $template); + + return response($zpl, 200, [ + 'Content-Type' => 'application/zpl', + 'Content-Disposition' => 'attachment; filename=label-unit-'.$this->unitFileName($serialUnit).'.zpl', + ]); + } + public function printMultiple(PrintMultipleLabelsRequest $request) { $validated = $request->validated(); @@ -109,6 +129,7 @@ public function printMultiple(PrintMultipleLabelsRequest $request) LabelTemplate::TYPE_FINISHED_GOODS => $this->multiFinishedGoods($validated['ids'], $template, $validated['format']), LabelTemplate::TYPE_WORKSTATION_STEP => $this->multiBatchSteps($validated['ids'], $template, $validated['format']), LabelTemplate::TYPE_PALLET => $this->multiPallets($validated['ids'], $template, $validated['format']), + LabelTemplate::TYPE_SERIAL_UNIT => $this->multiSerialUnits($validated['ids'], $template, $validated['format']), }; } @@ -127,6 +148,21 @@ private function multiPallets(array $ids, LabelTemplate $template, string $forma return $this->generator->pdfForPallets($pallets, $template)->stream("{$filename}.pdf"); } + private function multiSerialUnits(array $ids, LabelTemplate $template, string $format) + { + $units = SerialUnit::whereIn('id', $ids)->get(); + $filename = 'labels-serial-units-'.date('Ymd-His'); + + if ($format === 'zpl') { + return response($this->generator->zplForSerialUnits($units, $template), 200, [ + 'Content-Type' => 'application/zpl', + 'Content-Disposition' => "attachment; filename={$filename}.zpl", + ]); + } + + return $this->generator->pdfForSerialUnits($units, $template)->stream("{$filename}.pdf"); + } + private function multiWorkOrders(array $ids, LabelTemplate $template, string $format) { $workOrders = WorkOrder::whereIn('id', $ids)->get(); @@ -172,6 +208,11 @@ private function multiBatchSteps(array $ids, LabelTemplate $template, string $fo return $this->generator->pdfForBatchSteps($steps, $template)->stream("{$filename}.pdf"); } + private function unitFileName(SerialUnit $unit): string + { + return preg_replace('/\s+/', '_', (string) $unit->serial_no); + } + private function resolveTemplate(Request $request, string $type): LabelTemplate { if ($id = $request->integer('template')) { diff --git a/backend/app/Http/Controllers/Web/Packaging/PackagingController.php b/backend/app/Http/Controllers/Web/Packaging/PackagingController.php index 80c6ee4e5..418d4fd5b 100644 --- a/backend/app/Http/Controllers/Web/Packaging/PackagingController.php +++ b/backend/app/Http/Controllers/Web/Packaging/PackagingController.php @@ -8,6 +8,7 @@ use App\Http\Requests\PackagingScanRequest; use App\Models\PackagingScanLog; use App\Models\Pallet; +use App\Models\SerialUnit; use App\Models\WorkOrder; use App\Models\WorkOrderEan; use App\Services\Production\PalletBackflushService; @@ -128,6 +129,43 @@ public function scan(PackagingScanRequest $request) ]); } + /** + * Carton label by process serial number: the operator scans the unit's + * process serial label and gets the unit's serial number back with + * ready-to-print label URLs. + */ + public function scanUnit(Request $request) + { + $request->validate(['psn' => ['required', 'string', 'max:100']]); + + $psn = trim($request->string('psn')->toString()); + + $unit = SerialUnit::where('psn', $psn)->first(); + + if (! $unit) { + return response()->json(['message' => __('Unknown process serial number')], 404); + } + + $unit->loadMissing('workOrder.productType'); + $wo = $unit->workOrder; + + return response()->json([ + 'unit' => [ + 'id' => $unit->id, + 'serial_no' => $unit->serial_no, + 'psn' => $unit->psn, + 'status' => $unit->status, + 'work_order' => $wo ? [ + 'order_no' => $wo->order_no, + 'product' => $this->productLabel($wo), + ] : null, + ], + 'label_pdf' => route('packaging.labels.serial-unit.pdf', $unit), + 'label_zpl' => route('packaging.labels.serial-unit.zpl', $unit), + 'message' => __('Unit :sn found - print the carton label.', ['sn' => $unit->serial_no]), + ]); + } + // ── Pallets (packing station) ─────────────────────────────────────────────── public function openPallets(Request $request) diff --git a/backend/app/Models/LabelTemplate.php b/backend/app/Models/LabelTemplate.php index a78d61fd1..53b538caa 100644 --- a/backend/app/Models/LabelTemplate.php +++ b/backend/app/Models/LabelTemplate.php @@ -20,11 +20,14 @@ class LabelTemplate extends Model const TYPE_PALLET = 'pallet'; + const TYPE_SERIAL_UNIT = 'serial_unit'; + const TYPES = [ self::TYPE_WORK_ORDER => 'Work Order', self::TYPE_FINISHED_GOODS => 'Finished Goods', self::TYPE_WORKSTATION_STEP => 'Workstation Step', self::TYPE_PALLET => 'Pallet', + self::TYPE_SERIAL_UNIT => 'Serial Unit (SN)', ]; const SIZES = [ @@ -44,6 +47,8 @@ class LabelTemplate extends Model const AVAILABLE_FIELDS = [ 'wo_number' => 'Work order number', 'pallet_no' => 'Pallet number', + 'serial_no' => 'Serial number (SN)', + 'psn' => 'Process serial (PSN)', 'product' => 'Product name', 'quantity' => 'Quantity', 'barcode' => 'Barcode (1D)', @@ -121,6 +126,19 @@ public static function defaultFieldsFor(string $type): array 'location' => true, 'prod_date' => true, ], + self::TYPE_SERIAL_UNIT => [ + 'serial_no' => true, + 'psn' => true, + 'wo_number' => true, + 'product' => true, + 'quantity' => false, + 'barcode' => true, + 'qr' => true, + 'logo' => false, + 'lot' => false, + 'location' => false, + 'prod_date' => true, + ], default => array_fill_keys(array_keys(self::AVAILABLE_FIELDS), false), }; } diff --git a/backend/app/Models/SerialUnit.php b/backend/app/Models/SerialUnit.php index a4626de8f..9064f05cc 100644 --- a/backend/app/Models/SerialUnit.php +++ b/backend/app/Models/SerialUnit.php @@ -35,6 +35,7 @@ class SerialUnit extends Model protected $fillable = [ 'serial_no', + 'psn', 'work_order_id', 'batch_id', 'material_id', diff --git a/backend/app/Services/Lot/LotPatternFormatter.php b/backend/app/Services/Lot/LotPatternFormatter.php index 13454d7a1..74500c13f 100644 --- a/backend/app/Services/Lot/LotPatternFormatter.php +++ b/backend/app/Services/Lot/LotPatternFormatter.php @@ -10,7 +10,8 @@ * Supported tokens: * [seq] sequence number, zero-padded to pad_size * [date] date as Ymd (e.g. 20260606) - * [date:FMT] date with a custom PHP date format (e.g. [date:y-m-d]) + * [date:FMT] date with a custom PHP date format (e.g. [date:y-m-d]); + * extra alias y1 = 1-digit year (2026 -> 6) * [year] 4-digit year * [month] 2-digit month * [day] 2-digit day of month @@ -33,7 +34,9 @@ public function format(string $pattern, int $number, int $padSize, ?string $prod return preg_replace_callback(self::TOKEN_REGEX, function (array $m) use ($number, $padSize, $productCode, $now) { return match ($m[1]) { 'seq' => str_pad((string) $number, $padSize, '0', STR_PAD_LEFT), - 'date' => $now->format($m[2] ?? 'Ymd'), + 'date' => $m[2] === 'y1' + ? (string) ($now->year % 10) + : $now->format($m[2] ?? 'Ymd'), 'year' => $now->format('Y'), 'month' => $now->format('m'), 'day' => $now->format('d'), diff --git a/backend/app/Services/Packaging/LabelGenerator.php b/backend/app/Services/Packaging/LabelGenerator.php index 65085ed41..6ec34accc 100644 --- a/backend/app/Services/Packaging/LabelGenerator.php +++ b/backend/app/Services/Packaging/LabelGenerator.php @@ -13,6 +13,8 @@ use Illuminate\Support\Collection; use Picqer\Barcode\BarcodeGeneratorPNG; +use App\Models\SerialUnit; + class LabelGenerator { public function pdfForWorkOrders(Collection $workOrders, LabelTemplate $template) @@ -43,6 +45,13 @@ public function pdfForPallets(Collection $pallets, LabelTemplate $template) return $this->renderPdf('packaging.pdf.labels.pallet', $labels, $template); } + public function pdfForSerialUnits(Collection $units, LabelTemplate $template) + { + $labels = $units->map(fn (SerialUnit $unit) => $this->labelDataForSerialUnit($unit, $template))->all(); + + return $this->renderPdf('packaging.pdf.labels.serial-unit', $labels, $template); + } + public function zplForPallets(Collection $pallets, LabelTemplate $template): string { return $pallets @@ -71,6 +80,42 @@ public function zplForBatchSteps(Collection $steps, LabelTemplate $template): st ->implode("\n"); } + public function zplForSerialUnits(Collection $units, LabelTemplate $template): string + { + return $units + ->map(fn (SerialUnit $unit) => $this->zplLabel($this->labelDataForSerialUnit($unit, $template), $template)) + ->implode("\n"); + } + + /** + * Per-unit carton label: the serial number is the primary value (barcode + + * QR); the process serial number is also printed so the unit can be + * re-found at packing by process-serial scan. + */ + private function labelDataForSerialUnit(SerialUnit $unit, LabelTemplate $template): array + { + $unit->loadMissing('workOrder.productType'); + $wo = $unit->workOrder; + $barcodeValue = $unit->serial_no; + $qrValue = url('/admin/traceability?q=' . rawurlencode($unit->serial_no)); + + return [ + 'fields' => [ + 'serial_no' => $unit->serial_no, + 'psn' => $unit->psn, + 'wo_number' => $wo?->order_no, + 'product' => $wo?->productType?->name, + 'quantity' => null, + 'lot' => null, + 'prod_date' => ($unit->produced_at ?? $unit->created_at)?->format('Y-m-d'), + ], + 'barcode_value' => $barcodeValue, + 'qr_value' => $qrValue, + 'barcode_png' => $template->hasField('barcode') ? $this->barcodePng($barcodeValue, $template->barcode_format) : null, + 'qr_png' => $template->hasField('qr') ? $this->qrPng($qrValue) : null, + ]; + } + private function labelDataForWorkOrder(WorkOrder $wo, LabelTemplate $template): array { $wo->loadMissing('productType', 'line'); @@ -241,6 +286,14 @@ private function zplLabel(array $data, LabelTemplate $template): string $zpl .= "^FO20,{$y}^A0N,32,32^FD".$this->zplEscape($fields['pallet_no'])."^FS\n"; $y += $lineHeight + 4; } + if ($template->hasField('serial_no') && ! empty($fields['serial_no'])) { + $zpl .= "^FO20,{$y}^A0N,32,32^FD".$this->zplEscape($fields['serial_no'])."^FS\n"; + $y += $lineHeight + 4; + } + if ($template->hasField('psn') && ! empty($fields['psn'])) { + $zpl .= "^FO20,{$y}^A0N,20,20^FDPSN: ".$this->zplEscape($fields['psn'])."^FS\n"; + $y += $lineHeight; + } if ($template->hasField('wo_number') && ! empty($fields['wo_number'])) { $zpl .= "^FO20,{$y}^A0N,28,28^FD".$this->zplEscape($fields['wo_number'])."^FS\n"; $y += $lineHeight; diff --git a/backend/app/Services/Traceability/SerialTraceService.php b/backend/app/Services/Traceability/SerialTraceService.php index 22de9f77c..3b8eb0d12 100644 --- a/backend/app/Services/Traceability/SerialTraceService.php +++ b/backend/app/Services/Traceability/SerialTraceService.php @@ -23,6 +23,7 @@ public function registerUnit(string $serialNo, array $attributes = []): SerialUn return SerialUnit::firstOrCreate( ['serial_no' => $serialNo, 'tenant_id' => $attributes['tenant_id'] ?? null], [ + 'psn' => $attributes['psn'] ?? null, 'work_order_id' => $attributes['work_order_id'] ?? null, 'batch_id' => $attributes['batch_id'] ?? null, 'material_id' => $attributes['material_id'] ?? null, diff --git a/backend/database/migrations/2026_09_23_100000_add_psn_to_serial_units_table.php b/backend/database/migrations/2026_09_23_100000_add_psn_to_serial_units_table.php new file mode 100644 index 000000000..343a18e38 --- /dev/null +++ b/backend/database/migrations/2026_09_23_100000_add_psn_to_serial_units_table.php @@ -0,0 +1,30 @@ +string('psn', 100)->nullable()->after('serial_no'); + $table->index('psn'); + }); + } + + public function down(): void + { + Schema::table('serial_units', function (Blueprint $table) { + $table->dropIndex(['psn']); + $table->dropColumn('psn'); + }); + } +}; diff --git a/backend/database/seeders/TraceabilityDemoSeeder.php b/backend/database/seeders/TraceabilityDemoSeeder.php index 34cdc8311..02ed544ba 100644 --- a/backend/database/seeders/TraceabilityDemoSeeder.php +++ b/backend/database/seeders/TraceabilityDemoSeeder.php @@ -225,6 +225,7 @@ private function seedFinishedBikeOrder(Line $line, array $stations, array $mater $failed = $n === 5; $unit = SerialUnit::create([ 'serial_no' => sprintf('TR-SN-%04d', $n), + 'psn' => sprintf('%s-%02d-%03d', now()->format('z'), now()->format('y'), $n), 'work_order_id' => $order->id, 'batch_id' => $batch->id, 'material_id' => $materials['bike']->id, diff --git a/backend/resources/js/Pages/admin/traceability/Index.jsx b/backend/resources/js/Pages/admin/traceability/Index.jsx index 53aa2af26..f65d9106a 100644 --- a/backend/resources/js/Pages/admin/traceability/Index.jsx +++ b/backend/resources/js/Pages/admin/traceability/Index.jsx @@ -857,6 +857,11 @@ function SerialResult({ unit, recall, components }) { {__('Serial unit')}

{unit.serial_no}

+ {unit.psn && ( +

+ {__('Process serial (PSN)')}: {unit.psn} +

+ )}

{__('Product')}: {unit.product ?? '—'} {unit.work_order && <> · {__('Work Order')}: {unit.work_order}} diff --git a/backend/resources/js/Pages/operator/unit-labels/Station.jsx b/backend/resources/js/Pages/operator/unit-labels/Station.jsx new file mode 100644 index 000000000..9dbc79258 --- /dev/null +++ b/backend/resources/js/Pages/operator/unit-labels/Station.jsx @@ -0,0 +1,235 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Head, usePage } from '@inertiajs/react'; +import { Button, Dropdown } from '@openmes/ui'; +import AppDataTable from '../../../components/AppDataTable'; +import AppLayout from '../../../layouts/AppLayout'; +import { __ } from '../../../lib/i18n'; + +function csrf() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.content : ''; +} + +function statusTone(status) { + if (status === 'scrapped') return 'text-red-600'; + if (status === 'done') return 'text-emerald-600'; + return 'text-om-accent'; +} + +export default function Station() { + const { workOrders = [] } = usePage().props; + + const [woId, setWoId] = useState(''); + const [psn, setPsn] = useState(''); + const [serialNo, setSerialNo] = useState(''); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); // { success, created?, unit?, error? } + const [flash, setFlash] = useState(null); // 'success' | 'error' | null + const [units, setUnits] = useState([]); + + const fetchUnits = useCallback(async () => { + try { + const qs = woId ? `?work_order_id=${encodeURIComponent(woId)}` : ''; + const res = await fetch(`/operator/unit-labels/units${qs}`, { + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + if (res.ok) { + const data = await res.json(); + setUnits(data.units ?? []); + } + } catch { + // Non-critical: table keeps its last state. + } + }, [woId]); + + useEffect(() => { + fetchUnits(); + }, [fetchUnits]); + + const flashIt = (kind) => { + setFlash(kind); + setTimeout(() => setFlash(null), 2000); + }; + + const applyLabel = useCallback(async () => { + const sn = serialNo.trim(); + if (!sn) { + setResult({ success: false, error: __('Scan or type the serial number first.') }); + return; + } + setBusy(true); + try { + const res = await fetch('/operator/unit-labels/apply', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': csrf(), + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + serial_no: sn, + psn: psn.trim() || null, + work_order_id: woId ? Number(woId) : null, + }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + setResult({ success: true, created: !!data.created, unit: data.unit, message: data.message }); + setPsn(''); + setSerialNo(''); + flashIt('success'); + } else { + const msg = data.errors + ? Object.values(data.errors).flat().join(' ') + : (data.message || __('Could not apply label.')); + setResult({ success: false, error: msg }); + flashIt('error'); + } + fetchUnits(); + } catch { + setResult({ success: false, error: __('Network error, try again.') }); + } finally { + setBusy(false); + } + }, [psn, serialNo, woId, fetchUnits]); + + const unitColumns = useMemo(() => [ + { + id: 'serial_no', + accessorKey: 'serial_no', + header: __('Serial No'), + cell: ({ row }) => {row.original.serial_no}, + }, + { + id: 'psn', + accessorKey: 'psn', + header: 'PSN', + cell: ({ row }) => {row.original.psn || '-'}, + }, + { + id: 'work_order', + accessorKey: 'work_order', + header: __('Work Order'), + cell: ({ row }) => {row.original.work_order || '-'}, + }, + { + id: 'status', + accessorKey: 'status', + header: __('Status'), + cell: ({ row }) => ( + + {row.original.status} + + ), + }, + { + id: 'produced_at', + accessorKey: 'produced_at', + header: __('Applied At'), + cell: ({ row }) => ( + + {row.original.produced_at ? new Date(row.original.produced_at).toLocaleString() : '-'} + + ), + }, + ], []); + + return ( + <> + +

+
+
+

SN Label Station

+

+ {__('Apply the pre-printed serial number label and bind it to the process serial number (PSN).')} +

+
+
+ setWoId(v)} + placeholder={__('All work orders')} + options={[ + { value: '', label: __('All work orders') }, + ...workOrders.map((wo) => ({ + value: String(wo.id), + label: `${wo.order_no} - ${wo.product || wo.status}`, + })), + ]} + /> +
+
+ +
+
+
+ + setPsn(e.target.value)} + placeholder="260-26-1" + className="w-full font-mono text-lg px-3 py-2.5 border border-om-line rounded-md focus:outline-none focus:ring-2 focus:ring-om-accent" + /> +
+
+ + setSerialNo(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && !busy) applyLabel(); }} + placeholder="e.g. 0001" + className="w-full font-mono text-lg px-3 py-2.5 border border-om-line rounded-md focus:outline-none focus:ring-2 focus:ring-om-accent" + /> +
+ +
+ + {result && ( +
+ {result.success ? ( +
+ + {result.created ? __('Unit registered') : __('Already registered')} + + {result.unit?.serial_no} + PSN: {result.unit?.psn || '-'} + {result.unit?.work_order && {result.unit.work_order}} +
+ ) : ( + {result.error} + )} +
+ )} +
+ +
+
+

{__('Recent units (last 50)')}

+
+ +
+
+ + {flash && ( +
+ {flash === 'success' ? __('Saved') : __('Error')} +
+ )} + + ); +} + +Station.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/packaging/Station.jsx b/backend/resources/js/Pages/packaging/Station.jsx index a01d18eac..3a3930a16 100644 --- a/backend/resources/js/Pages/packaging/Station.jsx +++ b/backend/resources/js/Pages/packaging/Station.jsx @@ -47,6 +47,9 @@ export default function Station() { const [history, setHistory] = useState([]); const [stats, setStats] = useState({ today_packed: 0, plan: 0, backlog: 0 }); const [lastScan, setLastScan] = useState(null); + const [psn, setPsn] = useState(''); + const [psnBusy, setPsnBusy] = useState(false); + const [psnResult, setPsnResult] = useState(null); // { success, unit?, error? } const [flash, setFlash] = useState(null); // 'success' | 'error' | null const [activePallet, setActivePallet] = useState(null); // { id, pallet_no, work_order_id, order_no, qty } const [openPallets, setOpenPallets] = useState([]); // all currently open pallets (persist across shifts) @@ -247,6 +250,35 @@ export default function Station() { setTimeout(() => setFlash(null), 2000); }, [fetchItems, fetchStats, fetchOpenPallets]); + // Scan the process serial number to open the matching carton label for printing. + const submitPsnLabel = useCallback(async (e) => { + e?.preventDefault(); + const term = psn.trim(); + if (!term || psnBusy) return; + setPsnBusy(true); + try { + const res = await fetch('/packaging/scan-unit', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf(), 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ psn: term }), + }); + const data = await res.json(); + if (res.ok) { + setPsnResult({ success: true, unit: data.unit, scanned_at: formatTime(new Date()) }); + setFlash('success'); + window.open(data.label_pdf, '_blank'); + setPsn(''); + } else { + setPsnResult({ success: false, error: data.message, scanned_at: formatTime(new Date()) }); + setFlash('error'); + } + } catch { + setPsnResult({ success: false, error: __('Connection error'), scanned_at: formatTime(new Date()) }); + setFlash('error'); + } + setTimeout(() => setFlash(null), 2000); + }, [psn, psnBusy]); + const createPallet = useCallback(async () => { if (!palletWoId || palletBusy) return; setPalletBusy(true); @@ -608,6 +640,36 @@ export default function Station() {
+ {/* Carton label by PSN (process serial number) */} +
+

+ {__('Carton label (scan PSN)')} +

+
+ setPsn(e.target.value)} + /> + + {psnResult && (psnResult.success ? ( + + {__('Serial Number')}: {psnResult.unit.serial_no} + {psnResult.unit.work_order && <> · {psnResult.unit.work_order.order_no}} + + ) : ( + {psnResult.error} + ))} +
+

+ {__('Scan the process serial number label. The printed label carries the unit serial number.')} +

+
+ {/* Items to pack */}
diff --git a/backend/resources/js/components/LabelPrintMenu.jsx b/backend/resources/js/components/LabelPrintMenu.jsx index 5d0cbcd92..9339a8c57 100644 --- a/backend/resources/js/components/LabelPrintMenu.jsx +++ b/backend/resources/js/components/LabelPrintMenu.jsx @@ -18,6 +18,7 @@ const KIND_TO_TYPE = { 'finished-goods': 'finished_goods', 'workstation-step': 'workstation_step', pallet: 'pallet', + 'serial-unit': 'serial_unit', }; export default function LabelPrintMenu({ kind, id, templates = [], label = 'Print Label' }) { diff --git a/backend/resources/views/packaging/pdf/labels/_label.blade.php b/backend/resources/views/packaging/pdf/labels/_label.blade.php index 8c6394209..0cbf9509d 100644 --- a/backend/resources/views/packaging/pdf/labels/_label.blade.php +++ b/backend/resources/views/packaging/pdf/labels/_label.blade.php @@ -3,6 +3,12 @@ + ); + } + return (
+ @if($template->hasField('serial_no') && !empty($label['fields']['serial_no'])) +
{{ $label['fields']['serial_no'] }}
+ @endif + @if($template->hasField('psn') && !empty($label['fields']['psn'])) +
PSN: {{ $label['fields']['psn'] }}
+ @endif @if($template->hasField('pallet_no') && !empty($label['fields']['pallet_no']))
{{ $label['fields']['pallet_no'] }}
@endif diff --git a/backend/resources/views/packaging/pdf/labels/serial-unit.blade.php b/backend/resources/views/packaging/pdf/labels/serial-unit.blade.php new file mode 100644 index 000000000..e554808d3 --- /dev/null +++ b/backend/resources/views/packaging/pdf/labels/serial-unit.blade.php @@ -0,0 +1,14 @@ + + + + + Serial Unit Labels + @include('packaging.pdf.labels._styles', ['widthMm' => $widthMm, 'heightMm' => $heightMm]) + + +@foreach($labels as $label) + @include('packaging.pdf.labels._label', ['label' => $label, 'template' => $template, 'widthMm' => $widthMm, 'heightMm' => $heightMm]) + @if(!$loop->last)
@endif +@endforeach + + diff --git a/backend/routes/web.php b/backend/routes/web.php index 029c565e6..cf3b41c55 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -53,6 +53,7 @@ use App\Http\Controllers\Web\Operator\ScrapController as OperatorScrapController; use App\Http\Controllers\Web\Operator\WorkOrderController as OperatorWorkOrderController; use App\Http\Controllers\Web\Operator\WorkstationController as OperatorWorkstationController; +use App\Http\Controllers\Web\Operator\UnitLabelStationController; use App\Http\Controllers\Web\Packaging\LabelPrintController; use App\Http\Controllers\Web\Packaging\LabelTemplateController; use App\Http\Controllers\Web\Packaging\PackagingController; @@ -298,6 +299,9 @@ function registerImportRoutes(): void // Workstation production view Route::get('/workstation', [OperatorWorkstationController::class, 'index'])->name('workstation'); + Route::get('/unit-labels/station', [UnitLabelStationController::class, 'index'])->name('unit-labels.station'); + Route::post('/unit-labels/apply', [UnitLabelStationController::class, 'apply'])->name('unit-labels.apply'); + Route::get('/unit-labels/units', [UnitLabelStationController::class, 'units'])->name('unit-labels.units'); Route::get('/workstation/check', [OperatorWorkstationController::class, 'check'])->name('workstation.check'); // Manual machine-state set (#87) — operator/supervisor sets a workstation's state. Route::post('/workstation/machine-state/{workstation}', [OperatorWorkstationController::class, 'setMachineState'])->name('workstation.machine-state'); @@ -934,6 +938,7 @@ function registerImportRoutes(): void Route::middleware('role:Operator|Supervisor|Admin')->group(function () { Route::get('/station', [PackagingController::class, 'station'])->name('station'); Route::post('/scan', [PackagingController::class, 'scan'])->name('scan'); + Route::post('/scan-unit', [PackagingController::class, 'scanUnit'])->name('scan-unit'); Route::get('/items', [PackagingController::class, 'items'])->name('items'); Route::get('/history', [PackagingController::class, 'history'])->name('history'); Route::get('/history/poll', [PackagingController::class, 'historyAfter'])->name('history.poll'); @@ -959,6 +964,8 @@ function registerImportRoutes(): void Route::get('/workstation-step/{batchStep}/zpl', [LabelPrintController::class, 'batchStepZpl'])->name('workstation-step.zpl'); Route::get('/pallet/{pallet}/pdf', [LabelPrintController::class, 'palletPdf'])->name('pallet.pdf'); Route::get('/pallet/{pallet}/zpl', [LabelPrintController::class, 'palletZpl'])->name('pallet.zpl'); + Route::get('/serial-unit/{serialUnit}/pdf', [LabelPrintController::class, 'serialUnitPdf'])->name('serial-unit.pdf'); + Route::get('/serial-unit/{serialUnit}/zpl', [LabelPrintController::class, 'serialUnitZpl'])->name('serial-unit.zpl'); Route::post('/print-multiple', [LabelPrintController::class, 'printMultiple'])->name('print-multiple'); }); From efb94c3925ff6e9e804452d130b546ca06536549 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Fri, 25 Sep 2026 13:52:45 +0200 Subject: [PATCH 03/21] refactor(users): move user administration validation into Form Requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule set lived inline in UserManagementController, in two near-identical copies — one in store(), one in update(). That is against the project's own convention that validation belongs in a Form Request, and it is why the two copies had drifted: only one of them carried the worker-code uniqueness ignore in a form that meant anything. StoreUserRequest holds the shared set; UpdateUserRequest extends it and overrides only what an edit changes — uniqueness ignores the record, the password becomes optional, and workforce values the record already holds stay acceptable. That last one is a deliberate behaviour change: editing somebody whose crew or wage group has since been deactivated now saves. Before, their own stored value was refused because the pickers no longer offered it. The worker screen already carries this fix, via the same trait. One form still writes to two tables — the account, and the personnel record addressed through worker_* keys. The mapping stays in the controller; only the rules moved. --- CHANGELOG.md | 9 + .../Web/Admin/UserManagementController.php | 63 +---- .../app/Http/Requests/StoreUserRequest.php | 89 +++++++ .../app/Http/Requests/UpdateUserRequest.php | 50 ++++ .../Admin/UserManagementFormRequestTest.php | 221 ++++++++++++++++++ 5 files changed, 375 insertions(+), 57 deletions(-) create mode 100644 backend/app/Http/Requests/StoreUserRequest.php create mode 100644 backend/app/Http/Requests/UpdateUserRequest.php create mode 100644 backend/tests/Feature/Web/Admin/UserManagementFormRequestTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 756f695af..323b84a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Changed + +- User administration validates through Form Requests. The rule set lived inline in the + controller in two near-identical copies, one per action, which the project's own conventions + forbid and which is how the two copies drifted apart. Behaviour is unchanged with one + deliberate exception: editing somebody whose crew or wage group has since been deactivated + now saves, where before their own stored value was refused because the pickers no longer + offered it. The same fix the worker screen already carries. + ## [0.24.3] - 2026-09-25 ### Changed diff --git a/backend/app/Http/Controllers/Web/Admin/UserManagementController.php b/backend/app/Http/Controllers/Web/Admin/UserManagementController.php index c6f0ceb3c..fa519def7 100644 --- a/backend/app/Http/Controllers/Web/Admin/UserManagementController.php +++ b/backend/app/Http/Controllers/Web/Admin/UserManagementController.php @@ -3,14 +3,13 @@ namespace App\Http\Controllers\Web\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreUserRequest; +use App\Http\Requests\UpdateUserRequest; use App\Models\User; use App\Models\Worker; use App\Models\Workstation; -use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; -use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\Password; use Inertia\Inertia; use Spatie\Permission\Models\Role; @@ -57,22 +56,6 @@ private function workforce(): \App\Extension\Contracts\WorkforceProvider return app(\App\Extension\Contracts\WorkforceProvider::class); } - /** - * Ids from an option list, for validating against exactly what was offered. - * - * Deliberately not `exists:crews,id`: that rule queries a table this - * installation may not have, and it would also accept a value the form - * never offered. An empty list rejects everything, which is the correct - * answer when nothing records crews. - * - * @param list $options - * @return list - */ - private function idsOf(array $options): array - { - return array_column($options, 'id'); - } - /** Shared option lists for the create/edit forms. */ private function formData(): array { @@ -93,26 +76,9 @@ private function formData(): array /** * Store a newly created user */ - public function store(Request $request) + public function store(StoreUserRequest $request) { - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255', 'regex:/^[\p{L}\p{N}\s\.\-\']+$/u'], - 'username' => 'required|string|max:255|unique:users', - 'email' => 'required|string|email|max:255|unique:users', - 'password' => ['required', 'confirmed', Password::defaults()], - 'role' => 'required_if:account_type,user|nullable|exists:roles,name', - 'account_type' => 'required|in:user,workstation', - 'workstation_id' => 'nullable|exists:workstations,id|required_if:account_type,workstation', - 'worker_code' => 'nullable|string|max:50|unique:workers,code', - 'worker_phone' => 'nullable|string|max:50', - 'worker_crew_id' => ['nullable', Rule::in($this->idsOf($this->workforce()->crewOptions()))], - 'worker_wage_group_id' => ['nullable', Rule::in($this->idsOf($this->workforce()->wageGroupOptions()))], - 'skills' => 'nullable|array', - 'skills.*.id' => ['required', Rule::in($this->idsOf($this->workforce()->skillOptions()))], - 'skills.*.level' => 'nullable|integer|min:1|max:5', - ], [ - 'name.regex' => 'Name may only contain letters, numbers, spaces, dots, hyphens, and apostrophes.', - ]); + $validated = $request->validated(); DB::transaction(function () use ($request, $validated) { $user = User::create([ @@ -195,26 +161,9 @@ public function edit(User $user) /** * Update the specified user */ - public function update(Request $request, User $user) + public function update(UpdateUserRequest $request, User $user) { - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255', 'regex:/^[\p{L}\p{N}\s\.\-\']+$/u'], - 'username' => 'required|string|max:255|unique:users,username,'.$user->id, - 'email' => 'required|string|email|max:255|unique:users,email,'.$user->id, - 'password' => ['nullable', 'confirmed', Password::defaults()], - 'role' => 'required_if:account_type,user|nullable|exists:roles,name', - 'account_type' => 'required|in:user,workstation', - 'workstation_id' => 'nullable|exists:workstations,id|required_if:account_type,workstation', - 'worker_code' => 'nullable|string|max:50|unique:workers,code,'.($user->worker_id ?? 'NULL'), - 'worker_phone' => 'nullable|string|max:50', - 'worker_crew_id' => ['nullable', Rule::in($this->idsOf($this->workforce()->crewOptions()))], - 'worker_wage_group_id' => ['nullable', Rule::in($this->idsOf($this->workforce()->wageGroupOptions()))], - 'skills' => 'nullable|array', - 'skills.*.id' => ['required', Rule::in($this->idsOf($this->workforce()->skillOptions()))], - 'skills.*.level' => 'nullable|integer|min:1|max:5', - ], [ - 'name.regex' => 'Name may only contain letters, numbers, spaces, dots, hyphens, and apostrophes.', - ]); + $validated = $request->validated(); $updateData = [ 'name' => $validated['name'], diff --git a/backend/app/Http/Requests/StoreUserRequest.php b/backend/app/Http/Requests/StoreUserRequest.php new file mode 100644 index 000000000..3e7144ee9 --- /dev/null +++ b/backend/app/Http/Requests/StoreUserRequest.php @@ -0,0 +1,89 @@ + ['required', 'string', 'max:255', 'regex:/^[\p{L}\p{N}\s\.\-\']+$/u'], + 'username' => ['required', 'string', 'max:255', $this->usernameUniqueness()], + 'email' => ['required', 'string', 'email', 'max:255', $this->emailUniqueness()], + 'password' => $this->passwordRules(), + 'role' => ['required_if:account_type,user', 'nullable', 'exists:roles,name'], + 'account_type' => ['required', 'in:user,workstation'], + 'workstation_id' => ['nullable', 'exists:workstations,id', 'required_if:account_type,workstation'], + 'worker_code' => ['nullable', 'string', 'max:50', $this->workerCodeUniqueness()], + 'worker_phone' => ['nullable', 'string', 'max:50'], + 'worker_crew_id' => ['nullable', 'integer', Rule::in($this->offeredWorkforceIds('crewOptions', $this->currentWorkerValue('crew_id')))], + 'worker_wage_group_id' => ['nullable', 'integer', Rule::in($this->offeredWorkforceIds('wageGroupOptions', $this->currentWorkerValue('wage_group_id')))], + 'skills' => ['nullable', 'array'], + 'skills.*.id' => ['required', Rule::in($this->offeredWorkforceIds('skillOptions'))], + 'skills.*.level' => ['nullable', 'integer', 'min:1', 'max:5'], + ]; + } + + public function messages(): array + { + return [ + 'name.regex' => __('Name may only contain letters, numbers, spaces, dots, hyphens, and apostrophes.'), + ]; + } + + protected function usernameUniqueness(): mixed + { + return Rule::unique('users', 'username'); + } + + protected function emailUniqueness(): mixed + { + return Rule::unique('users', 'email'); + } + + protected function workerCodeUniqueness(): mixed + { + return Rule::unique('workers', 'code'); + } + + /** A new account has no password yet, so one must be set. */ + protected function passwordRules(): array + { + return ['required', 'confirmed', Password::defaults()]; + } + + /** + * The worker value this account already holds, if any. + * + * Option lists can be narrower than the table — crewOptions() serves only + * active crews — so an edit of somebody in a since-deactivated crew has to + * keep validating. There is no such record on a create, hence null. + */ + protected function currentWorkerValue(string $attribute): ?int + { + return null; + } +} diff --git a/backend/app/Http/Requests/UpdateUserRequest.php b/backend/app/Http/Requests/UpdateUserRequest.php new file mode 100644 index 000000000..cc3972fa6 --- /dev/null +++ b/backend/app/Http/Requests/UpdateUserRequest.php @@ -0,0 +1,50 @@ +ignore($this->currentUser()?->id); + } + + protected function emailUniqueness(): mixed + { + return Rule::unique('users', 'email')->ignore($this->currentUser()?->id); + } + + protected function workerCodeUniqueness(): mixed + { + // Ignores the worker row this account points at, not the account itself. + return Rule::unique('workers', 'code')->ignore($this->currentUser()?->worker_id); + } + + /** Left blank, the existing password stands. */ + protected function passwordRules(): array + { + return ['nullable', 'confirmed', Password::defaults()]; + } + + protected function currentWorkerValue(string $attribute): ?int + { + $value = $this->currentUser()?->worker?->{$attribute}; + + return $value === null ? null : (int) $value; + } + + private function currentUser(): ?\App\Models\User + { + $user = $this->route('user'); + + return $user instanceof \App\Models\User ? $user : null; + } +} diff --git a/backend/tests/Feature/Web/Admin/UserManagementFormRequestTest.php b/backend/tests/Feature/Web/Admin/UserManagementFormRequestTest.php new file mode 100644 index 000000000..fcb09e95f --- /dev/null +++ b/backend/tests/Feature/Web/Admin/UserManagementFormRequestTest.php @@ -0,0 +1,221 @@ +create(); + $admin->assignRole('Admin'); + + return $admin; + } + + /** @return array */ + private function payload(array $overrides = []): array + { + return array_replace([ + 'account_type' => 'user', + 'name' => 'Anna Kowalska', + 'username' => 'akowalska', + 'email' => 'anna@example.test', + 'role' => 'Operator', + 'password' => 'StrongPass123!', + 'password_confirmation' => 'StrongPass123!', + ], $overrides); + } + + public function test_an_account_is_created_with_its_worker_details(): void + { + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->payload([ + 'worker_code' => 'EMP-1', + 'worker_phone' => '+48 600 700 800', + ])) + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.users.index')); + + $this->assertDatabaseHas('users', ['username' => 'akowalska']); + $this->assertDatabaseHas('workers', ['code' => 'EMP-1', 'phone' => '+48 600 700 800']); + } + + /** + * The worker row is created lazily and linked back onto the account. It is + * the most fragile part of this controller — two tables, one form — and the + * only thing that makes `$user->worker` resolve at all. + */ + public function test_creating_an_account_links_the_worker_row_back_onto_it(): void + { + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->payload(['worker_code' => 'EMP-2'])) + ->assertSessionHasNoErrors(); + + $user = User::where('username', 'akowalska')->firstOrFail(); + $worker = Worker::where('code', 'EMP-2')->firstOrFail(); + + $this->assertSame($worker->id, $user->worker_id); + $this->assertSame('anna@example.test', $worker->email); + $this->assertTrue((bool) $worker->is_active); + } + + public function test_no_worker_row_is_created_without_a_worker_code(): void + { + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->payload()) + ->assertSessionHasNoErrors(); + + $this->assertNull(User::where('username', 'akowalska')->firstOrFail()->worker_id); + $this->assertDatabaseCount('workers', 0); + } + + public function test_a_duplicate_username_is_refused(): void + { + User::factory()->create(['username' => 'akowalska']); + + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->payload()) + ->assertSessionHasErrors('username'); + + $this->assertDatabaseCount('users', 2); // the admin and the existing account + } + + public function test_a_duplicate_worker_code_is_refused(): void + { + Worker::factory()->create(['code' => 'EMP-3']); + + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->payload(['worker_code' => 'EMP-3'])) + ->assertSessionHasErrors('worker_code'); + + $this->assertDatabaseMissing('users', ['username' => 'akowalska']); + } + + public function test_an_unconfirmed_password_is_refused(): void + { + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->payload(['password_confirmation' => 'SomethingElse123!'])) + ->assertSessionHasErrors('password'); + } + + public function test_a_name_with_stray_characters_is_refused(): void + { + // The regex is the domain rule: a person's name is letters, not markup. + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->payload(['name' => ''])) + ->assertSessionHasErrors('name'); + } + + public function test_a_workstation_account_must_name_its_workstation(): void + { + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->payload(['account_type' => 'workstation', 'role' => null])) + ->assertSessionHasErrors('workstation_id'); + } + + public function test_an_edit_may_keep_its_own_username_and_email(): void + { + // The uniqueness rule has to ignore the record being edited, or renaming + // anything else about an account would fail on its own username. + $user = User::factory()->create(['username' => 'akowalska', 'email' => 'anna@example.test']); + + $this->actingAs($this->admin()) + ->put(route('admin.users.update', $user), $this->payload([ + 'name' => 'Anna Nowak', + 'password' => null, + 'password_confirmation' => null, + ])) + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.users.index')); + + $this->assertDatabaseHas('users', ['id' => $user->id, 'name' => 'Anna Nowak']); + } + + public function test_an_edit_may_keep_its_own_worker_code(): void + { + $worker = Worker::factory()->create(['code' => 'EMP-4']); + $user = User::factory()->create(['worker_id' => $worker->id, 'username' => 'akowalska', 'email' => 'anna@example.test']); + + $this->actingAs($this->admin()) + ->put(route('admin.users.update', $user), $this->payload([ + 'worker_code' => 'EMP-4', + 'worker_phone' => '111222333', + ])) + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('workers', ['id' => $worker->id, 'phone' => '111222333']); + $this->assertDatabaseCount('workers', 1); + } + + public function test_an_edit_leaving_the_password_blank_keeps_the_old_one(): void + { + $user = User::factory()->create(['username' => 'akowalska', 'email' => 'anna@example.test']); + $before = $user->password; + + $this->actingAs($this->admin()) + ->put(route('admin.users.update', $user), $this->payload([ + 'password' => null, + 'password_confirmation' => null, + ])) + ->assertSessionHasNoErrors(); + + $this->assertSame($before, $user->fresh()->password); + } + + public function test_an_edit_may_not_take_another_accounts_username(): void + { + User::factory()->create(['username' => 'taken']); + $user = User::factory()->create(['username' => 'akowalska', 'email' => 'anna@example.test']); + + $this->actingAs($this->admin()) + ->put(route('admin.users.update', $user), $this->payload(['username' => 'taken'])) + ->assertSessionHasErrors('username'); + } + + public function test_a_guest_cannot_create_an_account(): void + { + $this->post(route('admin.users.store'), $this->payload())->assertRedirect(); + + $this->assertDatabaseMissing('users', ['username' => 'akowalska']); + } + + public function test_an_operator_cannot_create_an_account(): void + { + $operator = User::factory()->create(); + $operator->assignRole('Operator'); + + $this->actingAs($operator) + ->post(route('admin.users.store'), $this->payload()) + ->assertForbidden(); + + $this->assertDatabaseMissing('users', ['username' => 'akowalska']); + } +} From 72f5b9f7669131932c4140c4b671726f2561820c Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Fri, 25 Sep 2026 14:18:20 +0200 Subject: [PATCH 04/21] feat(extension): let a module add fields to the user and worker forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module that contributes a field to a core form has to reach three places: the form must show it, the request must accept it, and something must store it. This is the middle one, and without it the other two are useless — validated() returns only the keys the rule set names, so a field nobody declared is dropped between the browser and the controller, silently and with no error to show. The four Form Requests behind the user and worker screens now pass their rules through FilterRegistry, the same mechanism the tab matrix, the soft-delete list and the sync shapes already use. With no module listening the array comes back untouched, so the community path costs nothing. The context carries the record being edited — a module needs it to write its own `unique … ignore` — and whether this is a create or an edit. Two conventions are documented on the trait rather than enforced: keys are prefixed `module__`, because the rule set is one flat array shared with core and an unprefixed key will eventually replace a core rule; and a filter receives the complete set and *can* weaken core rules, which is a real hole to weigh before accepting a third-party module. This is the validation half of the arrangement a separately distributed module needs. Such a module cannot deliver working React into a released install — the page globs are resolved at build time — so its field has to be rendered by core code from data the module supplies. --- CHANGELOG.md | 8 + .../Requests/Concerns/AllowsModuleFields.php | 76 +++++++ .../app/Http/Requests/StoreUserRequest.php | 16 +- .../app/Http/Requests/StoreWorkerRequest.php | 16 +- .../app/Http/Requests/UpdateUserRequest.php | 5 + .../app/Http/Requests/UpdateWorkerRequest.php | 16 +- .../Extension/ModuleValidationFilterTest.php | 200 ++++++++++++++++++ 7 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 backend/app/Http/Requests/Concerns/AllowsModuleFields.php create mode 100644 backend/tests/Feature/Extension/ModuleValidationFilterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 323b84a6b..bb66c7243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added + +- A module can add its own fields to the user and worker forms. The rule set of each + Form Request now passes through a `FilterRegistry` filter (`validation.admin.users`, + `validation.admin.workers`), which is what makes a module's key survive `validated()` — + without a declared rule it was dropped between the browser and the controller, silently. + With no module listening the rule set is returned untouched. + ### Changed - User administration validates through Form Requests. The rule set lived inline in the diff --git a/backend/app/Http/Requests/Concerns/AllowsModuleFields.php b/backend/app/Http/Requests/Concerns/AllowsModuleFields.php new file mode 100644 index 000000000..45b3c641c --- /dev/null +++ b/backend/app/Http/Requests/Concerns/AllowsModuleFields.php @@ -0,0 +1,76 @@ +_`, e.g. `module_example_code`. + * The rule set is one flat array shared with core; an unprefixed key will + * eventually collide with a core field, and the collision silently replaces + * the core rule. + * - **Add, do not subtract.** The filter receives the complete rule set and can + * rewrite any of it, including weakening core rules — dropping `confirmed` + * from a password, say. FilterRegistry has no notion of "append only" and this + * is not policed. It is a real hole in a third-party module; keep it in mind + * before accepting one. + */ +trait AllowsModuleFields +{ + /** + * The core rule set, plus whatever the installed modules add to it. + * + * With no module listening FilterRegistry returns the array untouched, so + * the community path costs nothing. + * + * @param array $rules + * @return array + */ + protected function withModuleFields(array $rules): array + { + return app(FilterRegistry::class)->filter( + $this->moduleFieldFilter(), + $rules, + $this->moduleFieldContext(), + ); + } + + /** The filter name, e.g. `validation.admin.users`. */ + abstract protected function moduleFieldFilter(): string; + + /** + * What the module needs in order to build its rules — at minimum the record + * being edited, so it can write its own `unique ... ignore`, and the action, + * so a create and an edit can differ. + * + * @return array + */ + protected function moduleFieldContext(): array + { + return ['action' => $this->moduleFieldAction()]; + } + + /** + * Whether this request creates or edits. A trait method cannot be reached + * through `parent::`, so overrides call this rather than the default above. + */ + protected function moduleFieldAction(): string + { + return $this->isMethod('POST') ? 'store' : 'update'; + } +} diff --git a/backend/app/Http/Requests/StoreUserRequest.php b/backend/app/Http/Requests/StoreUserRequest.php index 3e7144ee9..b00e27e68 100644 --- a/backend/app/Http/Requests/StoreUserRequest.php +++ b/backend/app/Http/Requests/StoreUserRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Http\Requests\Concerns\AllowsModuleFields; use App\Http\Requests\Concerns\ValidatesWorkforceIds; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -19,6 +20,7 @@ */ class StoreUserRequest extends FormRequest { + use AllowsModuleFields; use ValidatesWorkforceIds; public function authorize(): bool @@ -29,7 +31,7 @@ public function authorize(): bool public function rules(): array { - return [ + return $this->withModuleFields([ 'name' => ['required', 'string', 'max:255', 'regex:/^[\p{L}\p{N}\s\.\-\']+$/u'], 'username' => ['required', 'string', 'max:255', $this->usernameUniqueness()], 'email' => ['required', 'string', 'email', 'max:255', $this->emailUniqueness()], @@ -44,7 +46,7 @@ public function rules(): array 'skills' => ['nullable', 'array'], 'skills.*.id' => ['required', Rule::in($this->offeredWorkforceIds('skillOptions'))], 'skills.*.level' => ['nullable', 'integer', 'min:1', 'max:5'], - ]; + ]); } public function messages(): array @@ -54,6 +56,16 @@ public function messages(): array ]; } + protected function moduleFieldFilter(): string + { + return 'validation.admin.users'; + } + + protected function moduleFieldContext(): array + { + return ['action' => $this->moduleFieldAction(), 'user' => null]; + } + protected function usernameUniqueness(): mixed { return Rule::unique('users', 'username'); diff --git a/backend/app/Http/Requests/StoreWorkerRequest.php b/backend/app/Http/Requests/StoreWorkerRequest.php index 000805a7f..48b024a2b 100644 --- a/backend/app/Http/Requests/StoreWorkerRequest.php +++ b/backend/app/Http/Requests/StoreWorkerRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Http\Requests\Concerns\AllowsModuleFields; use App\Http\Requests\Concerns\MergesCustomFieldRules; use App\Http\Requests\Concerns\ValidatesWorkforceIds; use App\Models\Worker; @@ -10,6 +11,7 @@ class StoreWorkerRequest extends FormRequest { + use AllowsModuleFields; use MergesCustomFieldRules; use ValidatesWorkforceIds; @@ -24,6 +26,16 @@ protected function customFieldEntityType(): string return 'worker'; } + protected function moduleFieldFilter(): string + { + return 'validation.admin.workers'; + } + + protected function moduleFieldContext(): array + { + return ['action' => $this->moduleFieldAction(), 'worker' => null]; + } + public function rules(): array { // The record being edited may hold a value the pickers no longer @@ -31,7 +43,7 @@ public function rules(): array // value valid stops an unrelated edit from failing on it. $current = null; - return array_merge([ + return $this->withModuleFields(array_merge([ 'code' => ['required', 'string', 'max:50', 'unique:workers,code'], 'name' => ['required', 'string', 'max:255'], 'email' => ['nullable', 'email', 'max:255'], @@ -47,6 +59,6 @@ public function rules(): array 'skills' => ['nullable', 'array'], 'skills.*.id' => ['required', 'integer', Rule::in($this->offeredWorkforceIds('skillOptions'))], 'skills.*.level' => ['nullable', 'integer', 'min:1', 'max:5'], - ], $this->customFieldRules()); + ], $this->customFieldRules())); } } diff --git a/backend/app/Http/Requests/UpdateUserRequest.php b/backend/app/Http/Requests/UpdateUserRequest.php index cc3972fa6..4041e2b91 100644 --- a/backend/app/Http/Requests/UpdateUserRequest.php +++ b/backend/app/Http/Requests/UpdateUserRequest.php @@ -12,6 +12,11 @@ */ class UpdateUserRequest extends StoreUserRequest { + protected function moduleFieldContext(): array + { + return ['action' => $this->moduleFieldAction(), 'user' => $this->currentUser()]; + } + protected function usernameUniqueness(): mixed { return Rule::unique('users', 'username')->ignore($this->currentUser()?->id); diff --git a/backend/app/Http/Requests/UpdateWorkerRequest.php b/backend/app/Http/Requests/UpdateWorkerRequest.php index f956c427f..b48076086 100644 --- a/backend/app/Http/Requests/UpdateWorkerRequest.php +++ b/backend/app/Http/Requests/UpdateWorkerRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Http\Requests\Concerns\AllowsModuleFields; use App\Http\Requests\Concerns\MergesCustomFieldRules; use App\Http\Requests\Concerns\ValidatesWorkforceIds; use App\Models\Worker; @@ -10,6 +11,7 @@ class UpdateWorkerRequest extends FormRequest { + use AllowsModuleFields; use MergesCustomFieldRules; use ValidatesWorkforceIds; @@ -24,12 +26,22 @@ protected function customFieldEntityType(): string return 'worker'; } + protected function moduleFieldFilter(): string + { + return 'validation.admin.workers'; + } + + protected function moduleFieldContext(): array + { + return ['action' => $this->moduleFieldAction(), 'worker' => $this->route('worker')]; + } + public function rules(): array { $current = $this->route('worker'); $workerId = $current?->id; - return array_merge([ + return $this->withModuleFields(array_merge([ 'code' => ['required', 'string', 'max:50', Rule::unique('workers', 'code')->ignore($workerId)], 'name' => ['required', 'string', 'max:255'], 'email' => ['nullable', 'email', 'max:255'], @@ -45,6 +57,6 @@ public function rules(): array 'skills' => ['nullable', 'array'], 'skills.*.id' => ['required', 'integer', Rule::in($this->offeredWorkforceIds('skillOptions'))], 'skills.*.level' => ['nullable', 'integer', 'min:1', 'max:5'], - ], $this->customFieldRules()); + ], $this->customFieldRules())); } } diff --git a/backend/tests/Feature/Extension/ModuleValidationFilterTest.php b/backend/tests/Feature/Extension/ModuleValidationFilterTest.php new file mode 100644 index 000000000..93cf4a77e --- /dev/null +++ b/backend/tests/Feature/Extension/ModuleValidationFilterTest.php @@ -0,0 +1,200 @@ +create(); + $admin->assignRole('Admin'); + + return $admin; + } + + /** Stand in for an installed module contributing one field. */ + private function moduleContributes(string $filter, array $rules, ?callable $spy = null): void + { + app(FilterRegistry::class)->addFilter($filter, function ($coreRules, $context) use ($rules, $spy) { + if ($spy) { + $spy($context); + } + + return $coreRules + $rules; + }); + } + + /** @return array */ + private function userPayload(array $overrides = []): array + { + return array_replace([ + 'account_type' => 'user', + 'name' => 'Anna Kowalska', + 'username' => 'akowalska', + 'email' => 'anna@example.test', + 'role' => 'Operator', + 'password' => 'StrongPass123!', + 'password_confirmation' => 'StrongPass123!', + ], $overrides); + } + + public function test_an_undeclared_field_is_dropped_rather_than_refused(): void + { + // The starting point, and the reason the seam is needed. Nothing objects + // to the extra key; it simply never reaches the controller. + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->userPayload(['module_example_code' => 'ABC123'])) + ->assertSessionHasNoErrors() + ->assertRedirect(); + + $this->assertDatabaseHas('users', ['username' => 'akowalska']); + } + + public function test_a_module_rule_makes_its_field_survive_validation(): void + { + $this->moduleContributes('validation.admin.users', ['module_example_code' => ['required', 'string', 'max:32']]); + + // The rule is enforced, which is only possible if the key reached the + // validator — with the key dropped there would be nothing to complain + // about and the request would pass. + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->userPayload()) + ->assertSessionHasErrors('module_example_code'); + + $this->assertDatabaseMissing('users', ['username' => 'akowalska']); + } + + public function test_a_module_field_that_passes_its_rule_is_accepted(): void + { + $this->moduleContributes('validation.admin.users', ['module_example_code' => ['required', 'string', 'max:8']]); + + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->userPayload(['module_example_code' => 'ABC123'])) + ->assertSessionHasNoErrors() + ->assertRedirect(); + + $this->assertDatabaseHas('users', ['username' => 'akowalska']); + } + + public function test_a_module_field_is_validated_server_side(): void + { + // The point of routing this through the rule set rather than trusting + // the page: the module's own constraint is enforced by the backend. + $this->moduleContributes('validation.admin.users', ['module_example_code' => ['required', 'string', 'max:8']]); + + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->userPayload(['module_example_code' => str_repeat('X', 40)])) + ->assertSessionHasErrors('module_example_code'); + } + + public function test_the_module_is_told_which_record_is_being_edited(): void + { + // Without the record a module cannot write its own `unique … ignore`, + // so editing somebody would collide with their own stored value. + $user = User::factory()->create(['username' => 'akowalska', 'email' => 'anna@example.test']); + $context = null; + + $this->moduleContributes('validation.admin.users', [], function ($ctx) use (&$context) { + $context = $ctx; + }); + + $this->actingAs($this->admin()) + ->put(route('admin.users.update', $user), $this->userPayload(['password' => null, 'password_confirmation' => null])) + ->assertSessionHasNoErrors(); + + $this->assertSame('update', $context['action']); + $this->assertSame($user->id, $context['user']?->id); + } + + public function test_a_create_reports_itself_as_one(): void + { + $context = null; + $this->moduleContributes('validation.admin.users', [], function ($ctx) use (&$context) { + $context = $ctx; + }); + + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->userPayload()) + ->assertSessionHasNoErrors(); + + $this->assertSame('store', $context['action']); + $this->assertNull($context['user']); + } + + public function test_the_worker_form_carries_the_same_seam(): void + { + $this->moduleContributes('validation.admin.workers', ['module_example_code' => ['required', 'string']]); + + $this->actingAs($this->admin()) + ->post(route('admin.workers.store'), ['code' => 'W-1', 'name' => 'Jan Nowak', 'is_active' => true]) + ->assertSessionHasErrors('module_example_code'); + + $this->assertDatabaseMissing('workers', ['code' => 'W-1']); + } + + public function test_editing_a_worker_carries_the_record_too(): void + { + $worker = Worker::factory()->create(['code' => 'W-2']); + $context = null; + + $this->moduleContributes('validation.admin.workers', [], function ($ctx) use (&$context) { + $context = $ctx; + }); + + $this->actingAs($this->admin()) + ->put(route('admin.workers.update', $worker), ['code' => 'W-2', 'name' => 'Renamed', 'is_active' => true]) + ->assertSessionHasNoErrors(); + + $this->assertSame('update', $context['action']); + $this->assertSame($worker->id, $context['worker']?->id); + } + + public function test_core_rules_still_apply_with_a_module_present(): void + { + // A module adding a field must not cost the form its own validation. + $this->moduleContributes('validation.admin.users', ['module_example_code' => ['nullable', 'string']]); + + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->userPayload(['email' => 'not-an-email'])) + ->assertSessionHasErrors('email'); + } + + public function test_no_module_leaves_the_rule_set_untouched(): void + { + // The community path: FilterRegistry returns the array as given. + $this->assertFalse(app(FilterRegistry::class)->has('validation.admin.users')); + + $this->actingAs($this->admin()) + ->post(route('admin.users.store'), $this->userPayload()) + ->assertSessionHasNoErrors() + ->assertRedirect(); + } +} From eebdf694e7e46978b42fcc098419340ccbc09784 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Fri, 25 Sep 2026 14:36:04 +0200 Subject: [PATCH 05/21] feat(extension): render and store the form fields a module contributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HookRegistry has been complete and entirely unused since it was written. This puts it to work on the two screens a separately distributed module needs, and closes the last of the three gaps such a module faces: the field is shown, it is accepted (the rule filter, previous commit), and now it is stored. Shown: the user and worker forms resolve `display.admin.{users,workers}.form .fields` and hand the contributions to ModuleFields.jsx, which draws them from data. That indirection is not a preference — a module installed from a ZIP can never deliver working React, because the page globs are expanded by Vite when core is built. Contributing data core renders is the only thing that works. ALLOWED grows the keys that describe a field. `type` is checked against the three controls core can draw and anything else is dropped with a line in the log, the same degradation the browser already applies to a component this build does not contain. `required` is cosmetic: what the backend demands is decided by the module's validation rule, not by its contribution. Stored: dispatch() resolves a `persist.` point for its effect, called inside the controller's transaction. A module's write belongs to the same unit of work as the record it hangs off — otherwise a failure there leaves the account saved and its field silently missing. A listener that throws therefore rolls the save back, which is intended and is tested. The worker controller had no transaction at all; it has one now. Two things the tests caught rather than review: resolving the hooks in both formData() and edit() ran a module's callback twice, the first time with no record to read; and a hook point's name contains dots, so assertInertia's paths read every segment as another level of nesting. Verified in the running app with a stand-in module: the field renders with its label, help text and asterisk, the server's own validation error appears under it, and the persist hook receives the value. --- CHANGELOG.md | 18 +- backend/app/Extension/HookRegistry.php | 105 ++++++- .../Web/Admin/UserManagementController.php | 50 +++- .../Web/Admin/WorkerController.php | 71 ++++- backend/lang/en.json | 3 +- backend/lang/pl.json | 3 +- .../resources/js/Pages/admin/users/Edit.jsx | 6 +- .../js/Pages/admin/users/UserForm.jsx | 11 +- .../js/Pages/admin/workers/Create.jsx | 6 +- .../resources/js/Pages/admin/workers/Edit.jsx | 6 +- .../js/Pages/admin/workers/WorkerForm.jsx | 11 +- .../resources/js/components/ModuleFields.jsx | 93 ++++++ backend/resources/js/lib/moduleFields.js | 47 +++ backend/resources/js/lib/moduleFields.test.js | 64 ++++ .../Extension/ModuleFormFieldSeamTest.php | 282 ++++++++++++++++++ 15 files changed, 739 insertions(+), 37 deletions(-) create mode 100644 backend/resources/js/components/ModuleFields.jsx create mode 100644 backend/resources/js/lib/moduleFields.js create mode 100644 backend/resources/js/lib/moduleFields.test.js create mode 100644 backend/tests/Feature/Extension/ModuleFormFieldSeamTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index bb66c7243..ed130c8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added -- A module can add its own fields to the user and worker forms. The rule set of each - Form Request now passes through a `FilterRegistry` filter (`validation.admin.users`, - `validation.admin.workers`), which is what makes a module's key survive `validated()` — - without a declared rule it was dropped between the browser and the controller, silently. - With no module listening the rule set is returned untouched. +- **A module can add its own fields to the user and worker forms** — shown by core, + validated server-side, stored by the module. Three pieces: `HookRegistry` (complete but + until now unused) carries the field's description to the page, where `ModuleFields` draws + it; the Form Request rule sets pass through a `FilterRegistry` filter, which is what makes + the module's key survive `validated()` — without a declared rule it was dropped between the + browser and the controller, silently; and a new `persist.*` hook tells the module, inside + the controller's transaction, that the record was saved, so a failed write there rolls the + whole save back rather than leaving the field lost. With no module listening none of it + costs anything: the page is sent `{}` and the rule set comes back untouched. + + A module distributed as a ZIP cannot ship working React into a released install — the page + globs are expanded when core is built — so contributing data that core renders is the only + arrangement that works at all. ### Changed diff --git a/backend/app/Extension/HookRegistry.php b/backend/app/Extension/HookRegistry.php index f8cbe3fa5..ba15b89b5 100644 --- a/backend/app/Extension/HookRegistry.php +++ b/backend/app/Extension/HookRegistry.php @@ -2,6 +2,8 @@ namespace App\Extension; +use Illuminate\Support\Facades\Log; + /** * Display hooks — named points on a page where a module may contribute something. * @@ -15,6 +17,14 @@ * outlives the request, and a registry that accumulated across requests would * render a module's contribution twice on the second hit. * + * ⚠️ `component` works only for a module that was present when the core frontend + * was built. The page glob below is expanded by Vite at build time, and the + * release ZIP ships a prebuilt frontend — so a module INSTALLED FROM A ZIP can + * never deliver a working component, and naming one produces an empty region and + * a console warning. Such a module must contribute the data fields instead and + * let core render them. This is the same trap that produced a blank + * /admin/plant-reports; do not walk into it again. + * * Naming a hook point: `display..`, e.g. * display.admin.lines.form.fields * display.admin.lines.table.columns @@ -31,14 +41,43 @@ * * Deliberately NOT shared from HandleInertiaRequests: resolving every hook on * every request would run a module's queries on pages that never use them. + * + * The same registry also carries `persist.` points, resolved with + * dispatch() rather than render(): those are called for their effect, inside the + * controller's transaction, so a module can store what it contributed. */ class HookRegistry { /** @var array}>> */ private array $handlers = []; - /** Fields a contribution may carry; anything else is dropped. */ - private const ALLOWED = ['component', 'props', 'slot', 'title', 'metric', 'body', 'href', 'external', 'tone']; + /** + * Fields a contribution may carry; anything else is dropped. + * + * Two groups. The card fields (title/metric/body/href/external/tone) are what + * WidgetRegistry has always carried. The field fields (name/type/label/…) + * describe one form input, which is what a module needs to add a field to a + * core form — see ModuleFields.jsx for what renders them. + * + * `required` is cosmetic, an asterisk and nothing more: whether the value is + * actually demanded is decided by the module's validation rule, contributed + * separately through FilterRegistry. A contribution cannot make the backend + * demand anything. + */ + private const ALLOWED = [ + 'component', 'props', 'slot', + 'title', 'metric', 'body', 'href', 'external', 'tone', + 'name', 'type', 'label', 'value', 'options', 'placeholder', 'help', 'required', + ]; + + /** + * Input types a contributed field may ask for. + * + * Kept short on purpose. Every entry is a control core already renders, and a + * contribution naming anything else is dropped rather than guessed at — a + * page that renders an unknown control is how a typo becomes a broken form. + */ + private const FIELD_TYPES = ['text', 'select', 'checkbox']; /** * Contribute to a hook point. @@ -82,12 +121,41 @@ public function render(string $hook, array $context = []): array continue; } - $out[] = array_intersect_key($result, array_flip(self::ALLOWED)); + $contribution = array_intersect_key($result, array_flip(self::ALLOWED)); + + if (! $this->fieldTypeIsRenderable($contribution, $hook)) { + continue; + } + + $out[] = $contribution; } return $out; } + /** + * A contributed field naming a type core cannot draw is dropped, with a line + * in the log — the same degradation the browser side applies to a component + * this build does not contain. + * + * @param array $contribution + */ + private function fieldTypeIsRenderable(array $contribution, string $hook): bool + { + if (! isset($contribution['type']) || in_array($contribution['type'], self::FIELD_TYPES, true)) { + return true; + } + + Log::warning('A module contributed a field of a type this application cannot render.', [ + 'hook' => $hook, + 'type' => $contribution['type'], + 'field' => $contribution['name'] ?? null, + 'renderable' => self::FIELD_TYPES, + ]); + + return false; + } + /** * Resolve several hooks against one shared context — the shape a controller * hands to its page. Points with no listeners are omitted, so a community @@ -115,4 +183,35 @@ public function renderMany(array $hooks, array $context = []): array return $out; } + + /** + * Tell the listeners that a record was saved, so a module can store what it + * contributed to the form. + * + * Unlike render(), this returns nothing and is called for its effect. Call it + * INSIDE the controller's transaction: a module's write belongs to the same + * unit of work as the record it hangs off, or a failure there leaves the + * account saved and its module field silently lost. + * + * A listener that throws therefore rolls the whole save back, which is the + * intended behaviour and not an accident — see the transaction test. + * + * The context carries the validated input and the saved model; a module reads + * only its own prefixed keys from it. + * + * @param array $context + */ + public function dispatch(string $hook, array $context = []): void + { + $handlers = $this->handlers[$hook] ?? []; + usort($handlers, fn ($a, $b) => $a['order'] <=> $b['order']); + + foreach ($handlers as $handler) { + $payload = $handler['payload']; + + if (is_callable($payload)) { + $payload($context); + } + } + } } diff --git a/backend/app/Http/Controllers/Web/Admin/UserManagementController.php b/backend/app/Http/Controllers/Web/Admin/UserManagementController.php index fa519def7..f56a4794c 100644 --- a/backend/app/Http/Controllers/Web/Admin/UserManagementController.php +++ b/backend/app/Http/Controllers/Web/Admin/UserManagementController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Web\Admin; +use App\Extension\HookRegistry; use App\Http\Controllers\Controller; use App\Http\Requests\StoreUserRequest; use App\Http\Requests\UpdateUserRequest; @@ -15,6 +16,10 @@ class UserManagementController extends Controller { + private const FIELDS_HOOK = 'display.admin.users.form.fields'; + + private const SAVED_HOOK = 'persist.admin.users'; + /** * Display a listing of users. Rows live-sync via the `users` shape (safe * columns only); role + workstation names come as props keyed by id. @@ -56,10 +61,33 @@ private function workforce(): \App\Extension\Contracts\WorkforceProvider return app(\App\Extension\Contracts\WorkforceProvider::class); } - /** Shared option lists for the create/edit forms. */ - private function formData(): array + /** + * Fields an installed module contributes to this form. + * + * Empty on a community install, where the prop is `{}` and ModuleFields + * renders nothing. A module ships no JSX of its own — see HookRegistry. + * + * @return array>> + */ + private function moduleFields(?User $user = null): array + { + return app(HookRegistry::class)->renderMany( + [self::FIELDS_HOOK], + ['user' => $user, 'worker' => $user?->worker], + ); + } + + /** + * Shared option lists for the create/edit forms. + * + * Takes the record so module contributions are resolved once, against it — + * resolving them here without it and again in edit() would run a module's + * callback twice, the first time with no record to read. + */ + private function formData(?User $user = null): array { return [ + 'hooks' => $this->moduleFields($user), 'roles' => Role::orderBy('name')->pluck('name'), 'workstations' => Workstation::with('line:id,name')->orderBy('name')->get(['id', 'name', 'line_id']) ->map(fn ($w) => ['id' => $w->id, 'name' => $w->line ? "{$w->name} ({$w->line->name})" : $w->name]), @@ -118,6 +146,15 @@ public function store(StoreUserRequest $request) $user->update(['worker_id' => $worker->id]); } + + // Inside the transaction on purpose: a module's write belongs to the + // same unit of work as the account it hangs off. + app(HookRegistry::class)->dispatch(self::SAVED_HOOK, [ + 'action' => 'store', + 'user' => $user, + 'worker' => $user->worker, + 'input' => $validated, + ]); }); return redirect()->route('admin.users.index') @@ -131,7 +168,7 @@ public function edit(User $user) { $user->load(Worker::hasModuleRelation('skills') ? 'worker.skills' : 'worker'); - return Inertia::render('admin/users/Edit', array_merge($this->formData(), [ + return Inertia::render('admin/users/Edit', array_merge($this->formData($user), [ 'assignments' => [ 'lines' => $user->lines()->get(['lines.id', 'lines.name'])->map(fn ($line) => $line->only('id', 'name')), 'station' => ($user->worker?->workstation ?? $user->workstation)?->only('id', 'name', 'line_id'), @@ -226,6 +263,13 @@ public function update(UpdateUserRequest $request, User $user) ); } } + + app(HookRegistry::class)->dispatch(self::SAVED_HOOK, [ + 'action' => 'update', + 'user' => $user, + 'worker' => $user->fresh()->worker, + 'input' => $validated, + ]); }); return redirect()->route('admin.users.index') diff --git a/backend/app/Http/Controllers/Web/Admin/WorkerController.php b/backend/app/Http/Controllers/Web/Admin/WorkerController.php index 19b577c92..78ceb8540 100644 --- a/backend/app/Http/Controllers/Web/Admin/WorkerController.php +++ b/backend/app/Http/Controllers/Web/Admin/WorkerController.php @@ -2,16 +2,22 @@ namespace App\Http\Controllers\Web\Admin; +use App\Extension\HookRegistry; use App\Http\Controllers\Controller; use App\Http\Requests\StoreWorkerRequest; use App\Http\Requests\UpdateWorkerRequest; use App\Models\Worker; use App\Services\CustomFieldService; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; class WorkerController extends Controller { + private const FIELDS_HOOK = 'display.admin.workers.form.fields'; + + private const SAVED_HOOK = 'persist.admin.workers'; + /** * Display a listing of workers. */ @@ -95,12 +101,26 @@ public function show(Worker $worker, CustomFieldService $cf) ]); } + /** + * Fields an installed module contributes to this form. + * + * Empty on a community install, where the prop is `{}` and ModuleFields + * renders nothing. A module ships no JSX of its own — see HookRegistry. + * + * @return array>> + */ + private function moduleFields(?Worker $worker = null): array + { + return app(HookRegistry::class)->renderMany([self::FIELDS_HOOK], ['worker' => $worker]); + } + /** * Show the form for creating a new worker. */ public function create(CustomFieldService $cf) { return Inertia::render('admin/workers/Create', [ + 'hooks' => $this->moduleFields(), 'crews' => $this->workforce()->crewOptions(), 'wageGroups' => $this->workforce()->wageGroupOptions(), 'personnelClasses' => $this->workforce()->personnelClassOptions(), @@ -125,15 +145,25 @@ public function store(StoreWorkerRequest $request, CustomFieldService $cf) $validated['custom_fields'] = $cf->fromRequest($request, 'worker') ?: null; } - $worker = Worker::create($validated); + DB::transaction(function () use ($request, $validated) { + $worker = Worker::create($validated); - // The skills pivot belongs to the optional workforce module; without it - // there is no relation to sync and nothing was submitted anyway. - if (Worker::hasModuleRelation('skills')) { - $worker->skills()->sync( - collect($request->input('skills', []))->mapWithKeys(fn ($s) => [$s['id'] => ['level' => $s['level'] ?? 1]]) - ); - } + // The skills pivot belongs to the optional workforce module; without it + // there is no relation to sync and nothing was submitted anyway. + if (Worker::hasModuleRelation('skills')) { + $worker->skills()->sync( + collect($request->input('skills', []))->mapWithKeys(fn ($s) => [$s['id'] => ['level' => $s['level'] ?? 1]]) + ); + } + + // Inside the transaction on purpose: a module's write belongs to the + // same unit of work as the record it hangs off. + app(HookRegistry::class)->dispatch(self::SAVED_HOOK, [ + 'action' => 'store', + 'worker' => $worker, + 'input' => $validated, + ]); + }); return redirect()->route('admin.workers.index') ->with('success', 'Worker created successfully.'); @@ -153,6 +183,7 @@ public function edit(Worker $worker, CustomFieldService $cf) } return Inertia::render('admin/workers/Edit', [ + 'hooks' => $this->moduleFields($worker), 'worker' => [ 'id' => $worker->id, 'code' => $worker->code, @@ -197,15 +228,23 @@ public function update(UpdateWorkerRequest $request, Worker $worker, CustomField $validated['custom_fields'] = $cf->fromRequest($request, 'worker', $worker->custom_fields) ?: null; } - $worker->update($validated); + DB::transaction(function () use ($request, $worker, $validated) { + $worker->update($validated); - // Preserve certification metadata: update the legacy proficiency level - // without detaching existing rows (which would wipe cert_level etc.). - if (Worker::hasModuleRelation('skills')) { - $worker->skills()->syncWithoutDetaching( - collect($request->input('skills', []))->mapWithKeys(fn ($s) => [$s['id'] => ['level' => $s['level'] ?? 1]]) - ); - } + // Preserve certification metadata: update the legacy proficiency level + // without detaching existing rows (which would wipe cert_level etc.). + if (Worker::hasModuleRelation('skills')) { + $worker->skills()->syncWithoutDetaching( + collect($request->input('skills', []))->mapWithKeys(fn ($s) => [$s['id'] => ['level' => $s['level'] ?? 1]]) + ); + } + + app(HookRegistry::class)->dispatch(self::SAVED_HOOK, [ + 'action' => 'update', + 'worker' => $worker, + 'input' => $validated, + ]); + }); return redirect()->route('admin.workers.index') ->with('success', 'Worker updated successfully.'); diff --git a/backend/lang/en.json b/backend/lang/en.json index 9bb5d8904..8b8ebdab4 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -6384,5 +6384,6 @@ "This stop has no linked downtime record, so it cannot be classified or escalated.": "This stop has no linked downtime record, so it cannot be classified or escalated.", "Stop with no record": "Stop with no record", "NO RECORD": "NO RECORD", - "Sample data is already being loaded. Please wait for it to finish.": "Sample data is already being loaded. Please wait for it to finish." + "Sample data is already being loaded. Please wait for it to finish.": "Sample data is already being loaded. Please wait for it to finish.", + "Additional fields": "Additional fields" } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index f806b10b4..f5a932493 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -6384,5 +6384,6 @@ "This stop has no linked downtime record, so it cannot be classified or escalated.": "Ten postój nie ma powiązanego rekordu przestoju, więc nie można przypisać mu przyczyny ani go eskalować.", "Stop with no record": "Postój bez rekordu", "NO RECORD": "BRAK REKORDU", - "Sample data is already being loaded. Please wait for it to finish.": "Dane przykładowe są właśnie wczytywane. Poczekaj na zakończenie." + "Sample data is already being loaded. Please wait for it to finish.": "Dane przykładowe są właśnie wczytywane. Poczekaj na zakończenie.", + "Additional fields": "Pola dodatkowe" } diff --git a/backend/resources/js/Pages/admin/users/Edit.jsx b/backend/resources/js/Pages/admin/users/Edit.jsx index 4f8526d49..ea3bd2c5f 100644 --- a/backend/resources/js/Pages/admin/users/Edit.jsx +++ b/backend/resources/js/Pages/admin/users/Edit.jsx @@ -2,9 +2,10 @@ import { Head, Link, useForm, usePage } from '@inertiajs/react'; import { __ } from '../../../lib/i18n'; import AppLayout from '../../../layouts/AppLayout'; import UserForm from './UserForm'; +import { moduleFieldInitial } from '../../../lib/moduleFields'; export default function UserEdit() { - const { user, assignments = {}, roles = [], workstations = [], crews = [], wageGroups = [], skills = [] } = usePage().props; + const { user, assignments = {}, roles = [], workstations = [], crews = [], wageGroups = [], skills = [], hooks = {} } = usePage().props; const w = user.worker; const form = useForm({ @@ -21,6 +22,7 @@ export default function UserEdit() { worker_crew_id: w?.crew_id != null ? String(w.crew_id) : '', worker_wage_group_id: w?.wage_group_id != null ? String(w.wage_group_id) : '', skills: w?.skills ?? [], + ...moduleFieldInitial(hooks, 'display.admin.users.form.fields'), }); const submit = (e) => { @@ -39,7 +41,7 @@ export default function UserEdit() {
{__('Regular workstation')}: {assignments.station ? {assignments.station.name} : {__('Create a worker profile below, then assign it to a workstation.')}}

{__('The operator selects the working station after login using Change line.')}

} - + ); } diff --git a/backend/resources/js/Pages/admin/users/UserForm.jsx b/backend/resources/js/Pages/admin/users/UserForm.jsx index 7843244a7..432c2744f 100644 --- a/backend/resources/js/Pages/admin/users/UserForm.jsx +++ b/backend/resources/js/Pages/admin/users/UserForm.jsx @@ -3,6 +3,7 @@ import { __ } from '../../../lib/i18n'; import { Button, Checkbox, Dropdown, RadioGroup } from '@openmes/ui'; import { useState } from 'react'; import { nameControl } from '../../../lib/fieldName'; +import ModuleFields from '../../../components/ModuleFields'; /** * Bespoke create/edit form for user accounts. Conditional on `account_type`: @@ -13,7 +14,7 @@ import { nameControl } from '../../../lib/fieldName'; * password is confirmed (password + password_confirmation). On edit, leaving * password blank keeps the current one. */ -export default function UserForm({ form, roles = [], workstations = [], crews = [], wageGroups = [], skills = [], isEdit, onSubmit, bare = false, onCancel }) { +export default function UserForm({ form, roles = [], workstations = [], crews = [], wageGroups = [], skills = [], hooks, isEdit, onSubmit, bare = false, onCancel }) { const { data, setData, errors, processing } = form; const isUser = data.account_type === 'user'; @@ -176,6 +177,14 @@ export default function UserForm({ form, roles = [], workstations = [], crews = )} + +
- +
@@ -538,6 +537,19 @@ export default function Station() {

{__('Last scan')}

+ {scannerMode === 'manual' && ( +
+ setManualCode(e.target.value)} + placeholder={__('Enter EAN code')} + autoFocus + className="flex-1 font-mono text-[13px] bg-om-card border border-om-line rounded-om px-3 py-2 text-om-ink placeholder:text-om-faint focus:outline-none focus:border-om-accent" + /> + +
+ )} {!lastScan ? (
{__('Scan an EAN code…')} diff --git a/backend/resources/js/lib/useScanBuffer.js b/backend/resources/js/lib/useScanBuffer.js new file mode 100644 index 000000000..ee9952516 --- /dev/null +++ b/backend/resources/js/lib/useScanBuffer.js @@ -0,0 +1,87 @@ +import { useEffect, useRef } from 'react'; + +/** + * The keystroke machine behind a barcode/RFID reader that behaves as a keyboard. + * + * USB HID readers type their payload and finish with Enter, which arrives as a + * burst of keystrokes with no field focused. Buffering is the whole mechanism; + * the timeout discards a half-typed code so a stray key pressed minutes earlier + * cannot end up prefixed onto the next real scan. + * + * Kept separate from the hook, and free of React and the DOM, so the rules above + * can be tested — the test environment is `node`, with no renderer. + * + * @param {(code: string) => void} onScan + * @param {{timeout?: number}} options + */ +export function createScanBuffer(onScan, { timeout = 500 } = {}) { + let buffer = ''; + let timer = null; + + const cancelDiscard = () => { + if (timer) clearTimeout(timer); + timer = null; + }; + + return { + /** @param {{key: string, target?: {tagName?: string}}} event */ + handleKey(event) { + // Typing an order number into an input must not also read as a scan. + const tag = event.target?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + + if (event.key === 'Enter') { + const code = buffer.trim(); + buffer = ''; + cancelDiscard(); + if (code) onScan(code); + } else if (event.key.length === 1) { + buffer += event.key; + cancelDiscard(); + timer = setTimeout(() => { + buffer = ''; + timer = null; + }, timeout); + } + }, + reset() { + buffer = ''; + cancelDiscard(); + }, + }; +} + +/** + * Listen for a keyboard-wedge reader for as long as the component is mounted. + * + * @param {(code: string) => void} onScan called with each completed scan + * @param {{enabled?: boolean, timeout?: number}} options + * enabled: false detaches the listener entirely — for `manual` scanner mode, + * where the operator types into a field instead. + */ +export default function useScanBuffer(onScan, options = {}) { + const { enabled = true, timeout = 500 } = options; + + // The callback is read through a ref so that a re-render (which gives a + // fresh onScan) does not detach and re-attach the listener, and cannot drop + // a buffer mid-scan. + const onScanRef = useRef(onScan); + + useEffect(() => { + onScanRef.current = onScan; + }, [onScan]); + + useEffect(() => { + if (! enabled) return undefined; + + const scanner = createScanBuffer((code) => onScanRef.current?.(code), { timeout }); + const onKey = (e) => scanner.handleKey(e); + + document.addEventListener('keydown', onKey); + + return () => { + document.removeEventListener('keydown', onKey); + scanner.reset(); + }; + }, [enabled, timeout]); +} diff --git a/backend/resources/js/lib/useScanBuffer.test.js b/backend/resources/js/lib/useScanBuffer.test.js new file mode 100644 index 000000000..0cf7d4116 --- /dev/null +++ b/backend/resources/js/lib/useScanBuffer.test.js @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createScanBuffer } from './useScanBuffer'; + +/** A keydown as the reader delivers it: one character, nothing focused. */ +const key = (k, tagName = 'BODY') => ({ key: k, target: { tagName } }); + +/** Type a whole code the way a reader does, Enter included. */ +function scan(buffer, code, tagName = 'BODY') { + for (const ch of code) buffer.handleKey(key(ch, tagName)); + buffer.handleKey(key('Enter', tagName)); +} + +describe('createScanBuffer', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('reports a code once the reader sends Enter', () => { + const onScan = vi.fn(); + scan(createScanBuffer(onScan), '5901234567890'); + + expect(onScan).toHaveBeenCalledExactlyOnceWith('5901234567890'); + }); + + it('reports nothing until Enter arrives', () => { + const onScan = vi.fn(); + const buffer = createScanBuffer(onScan); + + for (const ch of '590123') buffer.handleKey(key(ch)); + + expect(onScan).not.toHaveBeenCalled(); + }); + + it('starts a fresh code after each scan', () => { + const onScan = vi.fn(); + const buffer = createScanBuffer(onScan); + + scan(buffer, 'AAA'); + scan(buffer, 'BBB'); + + expect(onScan.mock.calls).toEqual([['AAA'], ['BBB']]); + }); + + it('discards a half-typed code after the timeout', () => { + // Somebody brushing the keyboard must not leave a prefix sitting there + // to be glued onto whatever is scanned next. + const onScan = vi.fn(); + const buffer = createScanBuffer(onScan); + + buffer.handleKey(key('x')); + vi.advanceTimersByTime(500); + scan(buffer, '123'); + + expect(onScan).toHaveBeenCalledExactlyOnceWith('123'); + }); + + it('keeps buffering while the keys keep coming', () => { + // A reader's keystrokes are milliseconds apart, so the discard timer has + // to restart on every one of them, not run from the first. + const onScan = vi.fn(); + const buffer = createScanBuffer(onScan); + + for (const ch of '12345') { + buffer.handleKey(key(ch)); + vi.advanceTimersByTime(400); + } + buffer.handleKey(key('Enter')); + + expect(onScan).toHaveBeenCalledExactlyOnceWith('12345'); + }); + + it('ignores keys typed into a form field', () => { + // The station has its own inputs; typing into one is not a scan. + const onScan = vi.fn(); + const buffer = createScanBuffer(onScan); + + scan(buffer, '123', 'INPUT'); + scan(buffer, '456', 'TEXTAREA'); + scan(buffer, '789', 'SELECT'); + + expect(onScan).not.toHaveBeenCalled(); + }); + + it('reports nothing for a bare Enter', () => { + const onScan = vi.fn(); + createScanBuffer(onScan).handleKey(key('Enter')); + + expect(onScan).not.toHaveBeenCalled(); + }); + + it('ignores keys that are not characters', () => { + const onScan = vi.fn(); + const buffer = createScanBuffer(onScan); + + buffer.handleKey(key('Shift')); + buffer.handleKey(key('ArrowLeft')); + scan(buffer, '42'); + + expect(onScan).toHaveBeenCalledExactlyOnceWith('42'); + }); + + it('drops a partial code when reset', () => { + // What unmounting does: the next mount must not inherit a prefix. + const onScan = vi.fn(); + const buffer = createScanBuffer(onScan); + + buffer.handleKey(key('9')); + buffer.reset(); + scan(buffer, '7'); + + expect(onScan).toHaveBeenCalledExactlyOnceWith('7'); + }); + + it('honours a custom timeout', () => { + const onScan = vi.fn(); + const buffer = createScanBuffer(onScan, { timeout: 100 }); + + buffer.handleKey(key('x')); + vi.advanceTimersByTime(100); + scan(buffer, 'y'); + + expect(onScan).toHaveBeenCalledExactlyOnceWith('y'); + }); +}); diff --git a/backend/tests/Feature/Packaging/ScannerModeTest.php b/backend/tests/Feature/Packaging/ScannerModeTest.php new file mode 100644 index 000000000..50aa24da7 --- /dev/null +++ b/backend/tests/Feature/Packaging/ScannerModeTest.php @@ -0,0 +1,107 @@ +create(); + $operator->assignRole('Operator'); + + return $operator; + } + + private function storeMode(string $mode): void + { + DB::table('system_settings')->updateOrInsert( + ['key' => 'scanner_mode'], + ['value' => json_encode($mode), 'updated_at' => now()], + ); + } + + public function test_the_station_is_told_which_scanner_mode_is_configured(): void + { + $this->storeMode('manual'); + + $this->actingAs($this->operator()) + ->get(route('packaging.station')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('packaging/Station') + ->where('scannerMode', 'manual')); + } + + public function test_an_installation_that_never_chose_defaults_to_the_keyboard_reader(): void + { + // No row at all: the common case, and the one where a wrong default would + // silently stop every reader on the shop floor from working. + DB::table('system_settings')->where('key', 'scanner_mode')->delete(); + + $this->actingAs($this->operator()) + ->get(route('packaging.station')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page->where('scannerMode', 'hid')); + } + + public function test_the_mode_chosen_in_settings_is_the_one_the_station_receives(): void + { + Role::findOrCreate('Admin', 'web'); + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + $this->actingAs($admin) + ->post(route('settings.update-system'), $this->systemSettingsPayload(['scanner_mode' => 'manual'])) + ->assertSessionHasNoErrors(); + + $this->get(route('packaging.station')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page->where('scannerMode', 'manual')); + } + + public function test_an_unknown_mode_is_refused(): void + { + Role::findOrCreate('Admin', 'web'); + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + $this->actingAs($admin) + ->post(route('settings.update-system'), $this->systemSettingsPayload(['scanner_mode' => 'bluetooth'])) + ->assertSessionHasErrors('scanner_mode'); + } + + /** @return array */ + private function systemSettingsPayload(array $overrides = []): array + { + return array_replace([ + 'production_period' => 'none', + 'workflow_mode' => 'status', + 'schedule_view_mode' => 'weekly', + 'schedule_shifts_per_day' => 1, + 'schedule_horizon_weeks' => 6, + 'realtime_mode' => 'polling', + 'production_tracking_mode' => 'per_operation', + 'production_qty_edit_policy' => 'none', + 'scanner_mode' => 'hid', + ], $overrides); + } +} From 0175879663347a19cf146292af9aeb33c5dc86a0 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Sat, 26 Sep 2026 11:52:49 +0200 Subject: [PATCH 08/21] feat(modules): enforce requires_core, and run a module's uninstall hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ends of a module's life that were not guarded. `requires_core` has been in every manifest since modules existed and nothing ever read it. A module built against extension points this core does not have installed cleanly, enabled cleanly, and then quietly did nothing — its fields never rendered, its listeners were never called, and the only symptom was a support conversation. Both install and enable now check it and name the two versions. Enable is checked as well as install, because install-time checking does not cover a module shipped with the image or one installed before core was rolled back — and enable is the step that runs its migrations. CoreVersionConstraint is deliberately not a Composer-grade resolver: one optional operator and one version, which is all any manifest here uses. An unreadable constraint is refused rather than guessed at — a typo that quietly means "any version" is the exact outcome this is meant to prevent. A module declaring nothing is unaffected, which is most of them. uninstall() now calls the module's own uninstall hook first, while its classes are still on disk; afterwards there is nothing left to load, so the hook never ran at all. It is the module's only chance to undo what it did outside its own tables — permissions it registered, settings it wrote. A hook that throws aborts the uninstall and leaves the directory in place, so it can be retried once whatever it tripped over is dealt with; deleting anyway would run half an uninstall and lose the other half. Migrations are still not rolled back, and the success message now says so rather than leaving it to be discovered. Also moves the runInstaller docblock back onto runInstaller — it had been left stranded above migrationsPath, describing the wrong method. --- CHANGELOG.md | 10 + .../Web/Admin/ModulesController.php | 33 ++- backend/app/Services/ModuleManager.php | 88 +++++-- backend/app/Support/CoreVersionConstraint.php | 77 ++++++ backend/lang/en.json | 5 +- backend/lang/pl.json | 5 +- .../Feature/ModuleLifecycleGuardsTest.php | 237 ++++++++++++++++++ .../Support/CoreVersionConstraintTest.php | 73 ++++++ 8 files changed, 509 insertions(+), 19 deletions(-) create mode 100644 backend/app/Support/CoreVersionConstraint.php create mode 100644 backend/tests/Feature/ModuleLifecycleGuardsTest.php create mode 100644 backend/tests/Unit/Support/CoreVersionConstraintTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bd3b0ff0..b2efb2551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- **A module's `requires_core` is now enforced.** Every manifest has carried it and nothing + read it, so a module built against extension points this core does not have installed + cleanly and then quietly did nothing. Installing or enabling one now says so instead, + naming both versions. A module that declares no requirement is unaffected, and an + unreadable one is refused rather than treated as "any version". - **A module can add its own fields to the user and worker forms** — shown by core, validated server-side, stored by the module. Three pieces: `HookRegistry` (complete but until now unused) carries the field's description to the page, where `ModuleFields` draws @@ -25,6 +30,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- **Uninstalling a module now runs the module's own uninstall hook**, while its classes are + still on disk — afterwards there is nothing left to call, so the hook never ran at all. It + is a module's only chance to undo what it did outside its own tables. A hook that fails + leaves the module in place to be retried, and the success message now says plainly that the + module's database tables are kept. - **The scanner mode setting now does something.** Settings → System has offered a choice between a keyboard-wedge reader and manual entry since it was merged, and its own description promised the operator "a visible field" — but the packing station never read diff --git a/backend/app/Http/Controllers/Web/Admin/ModulesController.php b/backend/app/Http/Controllers/Web/Admin/ModulesController.php index a93a7592b..d9a079895 100644 --- a/backend/app/Http/Controllers/Web/Admin/ModulesController.php +++ b/backend/app/Http/Controllers/Web/Admin/ModulesController.php @@ -46,6 +46,19 @@ public function enable(Request $request, string $name) return redirect()->back()->with('error', __('Module ":name" not found.', ['name' => $name])); } + // The install-time check does not cover a module that was already on + // disk — shipped with the image, or installed before core was rolled + // back. Enabling is the other moment it matters, and the one that runs + // its migrations. + $requiresCore = $this->manager->manifest($name)['requires_core'] ?? null; + + if (! $this->manager->coreSatisfies($requiresCore)) { + return redirect()->route('admin.modules.index')->with('error', __( + 'Module ":name" requires OpenMES :required; this installation is :current.', + ['name' => $module['display_name'], 'required' => $requiresCore, 'current' => config('version.current')], + )); + } + $this->manager->enable($name); $this->clearCache(); @@ -133,11 +146,25 @@ public function destroy(string $name) return redirect()->back()->with('error', __('Module ":name" not found.', ['name' => $name])); } - $this->manager->uninstall($name); + try { + $this->manager->uninstall($name); + } catch (\Throwable $e) { + report($e); + + // The module is still on disk, so this can be retried once whatever + // its uninstall hook tripped over is dealt with. + return redirect()->route('admin.modules.index')->with('error', __( + 'Module ":name" could not be uninstalled: :msg', + ['name' => $module['display_name'], 'msg' => $e->getMessage()], + )); + } + $this->clearCache(); - return redirect()->route('admin.modules.index') - ->with('success', __('Module ":name" uninstalled.', ['name' => $module['display_name']])); + return redirect()->route('admin.modules.index')->with('success', __( + 'Module ":name" uninstalled. Its database tables were left in place; reinstalling keeps the data.', + ['name' => $module['display_name']], + )); } protected function clearCache(): void diff --git a/backend/app/Services/ModuleManager.php b/backend/app/Services/ModuleManager.php index 129c5d1a4..1aa1d101f 100644 --- a/backend/app/Services/ModuleManager.php +++ b/backend/app/Services/ModuleManager.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Support\CoreVersionConstraint; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -72,6 +73,37 @@ public function enabledNames(): array } } + /** + * The module's manifest, or an empty array when it has none. + * + * @return array + */ + public function manifest(string $name): array + { + $path = "{$this->modulesPath}/{$name}/module.json"; + + if (! is_file($path)) { + return []; + } + + return json_decode((string) file_get_contents($path), true) ?: []; + } + + /** + * Whether this core satisfies a module's `requires_core`. + * + * An unreadable constraint counts as unsatisfied rather than as "no + * constraint": a typo must not quietly widen what a module accepts. + */ + public function coreSatisfies(?string $constraint): bool + { + try { + return CoreVersionConstraint::isSatisfied($constraint); + } catch (\InvalidArgumentException) { + return false; + } + } + /** * Enable a module by name. */ @@ -150,6 +182,21 @@ public function installFromZip(string $zipPath): string throw new \RuntimeException('Invalid provider namespace: must start with "Modules\\".'); } + // The core version the module was built against. Nothing read this + // until now, so a module installed on an older core simply did not + // work: the seams it relies on were absent, and the admin was left + // guessing. Refusing the install says so once, at the only moment + // anybody can act on it. + if (! $this->coreSatisfies($manifest['requires_core'] ?? null)) { + $zip->close(); + throw new \RuntimeException(sprintf( + 'Module "%s" requires OpenMES %s; this installation is %s.', + $manifest['name'], + $manifest['requires_core'], + config('version.current'), + )); + } + // Validate module name (alphanumeric + hyphens only, no path traversal) if (! preg_match('/^[A-Za-z0-9_-]+$/', $manifest['name'])) { $zip->close(); @@ -206,9 +253,22 @@ public function installFromZip(string $zipPath): string /** * Delete an installed module directory. + * + * The module's own uninstall hook runs FIRST, while its classes are still + * on disk and autoloadable — after the directory is gone there is nothing + * left to call. It is the module's only chance to undo what it did outside + * its own tables: permissions it registered, settings it wrote. + * + * A throwing hook aborts the uninstall and leaves the directory in place. + * Deleting anyway would run half an uninstall and lose the other half with + * no way to retry; this way the administrator sees the failure and the + * module is still there to try again. The module's own migrations are NOT + * rolled back — see the note the controller shows. */ public function uninstall(string $name): void { + $this->runInstaller($name, 'uninstall'); + $this->disable($name); $dir = "{$this->modulesPath}/{$name}"; if (is_dir($dir)) { @@ -237,6 +297,20 @@ public function loadEnabled(\Illuminate\Foundation\Application $app): void } } + /** + * The module's own migrations directory, or null when it ships none. + * + * Needed because a module's migrations are registered by its service + * provider, which is only loaded at boot — so the process that *enables* a + * module cannot see them and has to be told where they are. + */ + public function migrationsPath(string $name): ?string + { + $path = "{$this->modulesPath}/{$name}/database/migrations"; + + return is_dir($path) ? $path : null; + } + /** * Run a module's optional installer hook. * @@ -252,20 +326,6 @@ public function loadEnabled(\Illuminate\Foundation\Application $app): void * * @param string $method install | uninstall */ - /** - * The module's own migrations directory, or null when it ships none. - * - * Needed because a module's migrations are registered by its service - * provider, which is only loaded at boot — so the process that *enables* a - * module cannot see them and has to be told where they are. - */ - public function migrationsPath(string $name): ?string - { - $path = "{$this->modulesPath}/{$name}/database/migrations"; - - return is_dir($path) ? $path : null; - } - public function runInstaller(string $name, string $method = 'install'): void { $installer = "Modules\\{$name}\\Installer"; diff --git a/backend/app/Support/CoreVersionConstraint.php b/backend/app/Support/CoreVersionConstraint.php new file mode 100644 index 000000000..5ff396d28 --- /dev/null +++ b/backend/app/Support/CoreVersionConstraint.php @@ -0,0 +1,77 @@ +=` must match before `>`. */ + private const OPERATORS = ['>=', '<=', '==', '!=', '>', '<', '=']; + + /** + * @param string|null $constraint e.g. `>=0.25.0`, or null when the module declares none + * @param string|null $current the running core version, `v` prefix optional + */ + public static function isSatisfied(?string $constraint, ?string $current = null): bool + { + $constraint = trim((string) $constraint); + + // No constraint is the normal case — most modules declare none, and a + // module that does not care which core it runs on is not an error. + if ($constraint === '') { + return true; + } + + [$operator, $required] = self::parse($constraint); + + return version_compare(self::normalise($current ?? config('version.current')), $required, $operator); + } + + /** + * @return array{0: string, 1: string} + * + * @throws \InvalidArgumentException when the constraint is not one operator and one version + */ + private static function parse(string $constraint): array + { + foreach (self::OPERATORS as $operator) { + if (str_starts_with($constraint, $operator)) { + $version = self::normalise(substr($constraint, strlen($operator))); + + return [$operator === '=' ? '==' : $operator, self::assertVersion($version, $constraint)]; + } + } + + // A bare version means "this one or newer". Modules are forward + // compatible far more often than not, and reading it as an exact match + // would strand every module on the day core is patched. + return ['>=', self::assertVersion(self::normalise($constraint), $constraint)]; + } + + private static function normalise(?string $version): string + { + return ltrim(trim((string) $version), 'vV'); + } + + private static function assertVersion(string $version, string $constraint): string + { + if (! preg_match('/^\d+(\.\d+)*([-+].+)?$/', $version)) { + throw new \InvalidArgumentException("Unreadable core version requirement: \"{$constraint}\"."); + } + + return $version; + } +} diff --git a/backend/lang/en.json b/backend/lang/en.json index aa7ed5498..afc32602d 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -6386,5 +6386,8 @@ "NO RECORD": "NO RECORD", "Sample data is already being loaded. Please wait for it to finish.": "Sample data is already being loaded. Please wait for it to finish.", "Additional fields": "Additional fields", - "Enter EAN code": "Enter EAN code" + "Enter EAN code": "Enter EAN code", + "Module \":name\" requires OpenMES :required; this installation is :current.": "Module \":name\" requires OpenMES :required; this installation is :current.", + "Module \":name\" could not be uninstalled: :msg": "Module \":name\" could not be uninstalled: :msg", + "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.": "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index b23326a09..978c4db91 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -6386,5 +6386,8 @@ "NO RECORD": "BRAK REKORDU", "Sample data is already being loaded. Please wait for it to finish.": "Dane przykładowe są właśnie wczytywane. Poczekaj na zakończenie.", "Additional fields": "Pola dodatkowe", - "Enter EAN code": "Wpisz kod EAN" + "Enter EAN code": "Wpisz kod EAN", + "Module \":name\" requires OpenMES :required; this installation is :current.": "Moduł \":name\" wymaga OpenMES :required; ta instalacja to :current.", + "Module \":name\" could not be uninstalled: :msg": "Nie udało się odinstalować modułu \":name\": :msg", + "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.": "Moduł \":name\" odinstalowany. Jego tabele w bazie zostały nienaruszone; ponowna instalacja zachowa dane." } diff --git a/backend/tests/Feature/ModuleLifecycleGuardsTest.php b/backend/tests/Feature/ModuleLifecycleGuardsTest.php new file mode 100644 index 000000000..ece32661f --- /dev/null +++ b/backend/tests/Feature/ModuleLifecycleGuardsTest.php @@ -0,0 +1,237 @@ +modulesPath = sys_get_temp_dir().'/openmes-lifecycle-'.uniqid(); + mkdir($this->modulesPath); + $this->app->instance(ModuleManager::class, new class($this->modulesPath) extends ModuleManager + { + public function __construct(string $path) + { + parent::__construct(); + $this->modulesPath = $path; + } + }); + + Event::fake(); + ProbeInstaller::$calls = []; + ProbeInstaller::$throwOnUninstall = false; + + // The alias survives the test that made it — PHP cannot forget a class + // within a process, and both tests below need it. + if (! class_exists('Modules\Probe\Installer', false)) { + class_alias(ProbeInstaller::class, 'Modules\Probe\Installer'); + } + } + + protected function tearDown(): void + { + exec('rm -rf '.escapeshellarg($this->modulesPath)); + + parent::tearDown(); + } + + private function admin(): User + { + Role::findOrCreate('Admin', 'web'); + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + return $admin; + } + + private function moduleZip(string $name, ?string $requiresCore): UploadedFile + { + $manifest = [ + 'name' => $name, + 'display_name' => "{$name} module", + 'version' => '1.0.0', + 'provider' => "Modules\\{$name}\\Providers\\{$name}ServiceProvider", + ]; + + if ($requiresCore !== null) { + $manifest['requires_core'] = $requiresCore; + } + + $path = tempnam(sys_get_temp_dir(), 'mod').'.zip'; + $zip = new \ZipArchive; + $zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFromString("{$name}/module.json", json_encode($manifest)); + $zip->addFromString( + "{$name}/Providers/{$name}ServiceProvider.php", + "close(); + + return new UploadedFile($path, 'module.zip', 'application/zip', null, true); + } + + /** Put a module on disk without going through the upload form. */ + private function installOnDisk(string $name, ?string $requiresCore): void + { + $manifest = ['name' => $name, 'display_name' => "{$name} module", 'version' => '1.0.0']; + + if ($requiresCore !== null) { + $manifest['requires_core'] = $requiresCore; + } + + mkdir("{$this->modulesPath}/{$name}"); + file_put_contents("{$this->modulesPath}/{$name}/module.json", json_encode($manifest)); + } + + public function test_a_module_built_for_a_newer_core_is_refused_at_install(): void + { + $response = $this->actingAs($this->admin()) + ->from('/admin/modules') + ->post('/admin/modules/upload', ['module_zip' => $this->moduleZip('Ahead', '>=99.0.0')]); + + $response->assertSessionHas('error', fn (string $m) => str_contains($m, '99.0.0')); + $this->assertDirectoryDoesNotExist("{$this->modulesPath}/Ahead"); + } + + public function test_a_module_built_for_this_core_installs(): void + { + $this->actingAs($this->admin()) + ->post('/admin/modules/upload', ['module_zip' => $this->moduleZip('Fine', '>=0.1.0')]) + ->assertSessionHas('success'); + + $this->assertFileExists("{$this->modulesPath}/Fine/module.json"); + } + + public function test_a_module_declaring_nothing_still_installs(): void + { + // The normal case, and the one a new check must not break. + $this->actingAs($this->admin()) + ->post('/admin/modules/upload', ['module_zip' => $this->moduleZip('Quiet', null)]) + ->assertSessionHas('success'); + + $this->assertFileExists("{$this->modulesPath}/Quiet/module.json"); + } + + public function test_an_unreadable_requirement_is_refused_rather_than_ignored(): void + { + // A typo that quietly meant "any version" would defeat the whole check. + $this->actingAs($this->admin()) + ->from('/admin/modules') + ->post('/admin/modules/upload', ['module_zip' => $this->moduleZip('Typo', 'latest')]) + ->assertSessionHas('error'); + + $this->assertDirectoryDoesNotExist("{$this->modulesPath}/Typo"); + } + + public function test_a_module_already_on_disk_cannot_be_enabled_on_too_old_a_core(): void + { + // Install-time checking does not cover a module shipped with the image, + // or one installed before core was rolled back. Enabling is the other + // moment it matters — and the one that runs its migrations. + $this->installOnDisk('Ahead', '>=99.0.0'); + + $this->actingAs($this->admin()) + ->from('/admin/modules') + ->post('/admin/modules/Ahead/enable') + ->assertSessionHas('error', fn (string $m) => str_contains($m, '99.0.0')); + + $this->assertNotContains('Ahead', app(ModuleManager::class)->enabledNames()); + } + + public function test_a_module_on_disk_with_a_met_requirement_enables(): void + { + $this->installOnDisk('Fine', '>=0.1.0'); + + $this->actingAs($this->admin()) + ->post('/admin/modules/Fine/enable') + ->assertSessionHas('success'); + + $this->assertContains('Fine', app(ModuleManager::class)->enabledNames()); + } + + public function test_uninstall_runs_the_modules_own_hook_before_deleting_it(): void + { + // After the directory is gone the Installer class cannot be loaded, so + // the hook would never run at all — which is what used to happen. + $this->installOnDisk('Probe', null); + + $this->actingAs($this->admin()) + ->delete('/admin/modules/Probe') + ->assertSessionHas('success'); + + $this->assertSame(['uninstall'], ProbeInstaller::$calls); + $this->assertDirectoryDoesNotExist("{$this->modulesPath}/Probe"); + } + + public function test_the_uninstall_message_says_the_tables_stay(): void + { + // Migrations are not rolled back. Saying so is the difference between a + // deliberate choice and a surprise. + $this->installOnDisk('Quiet', null); + + $this->actingAs($this->admin()) + ->delete('/admin/modules/Quiet') + ->assertSessionHas('success', fn (string $m) => str_contains($m, 'tables were left in place')); + } + + public function test_a_failing_uninstall_hook_leaves_the_module_in_place(): void + { + // Deleting anyway would run half an uninstall and lose the other half + // with no way to retry. + $this->installOnDisk('Probe', null); + ProbeInstaller::$throwOnUninstall = true; + + $this->actingAs($this->admin()) + ->from('/admin/modules') + ->delete('/admin/modules/Probe') + ->assertSessionHas('error', fn (string $m) => str_contains($m, 'Probe')); + + $this->assertFileExists("{$this->modulesPath}/Probe/module.json"); + } +} + +/** Stands in for a module shipping `Modules\\Installer`. */ +class ProbeInstaller +{ + /** @var list */ + public static array $calls = []; + + public static bool $throwOnUninstall = false; + + public function install(): void + { + self::$calls[] = 'install'; + } + + public function uninstall(): void + { + self::$calls[] = 'uninstall'; + + if (self::$throwOnUninstall) { + throw new \RuntimeException('Probe refuses to go quietly'); + } + } +} diff --git a/backend/tests/Unit/Support/CoreVersionConstraintTest.php b/backend/tests/Unit/Support/CoreVersionConstraintTest.php new file mode 100644 index 000000000..0e9f1d637 --- /dev/null +++ b/backend/tests/Unit/Support/CoreVersionConstraintTest.php @@ -0,0 +1,73 @@ + */ + public static function constraints(): array + { + return [ + // A module that declares nothing runs anywhere. Most do. + 'no constraint' => [null, '0.25.0', true], + 'empty constraint' => ['', '0.25.0', true], + + 'at least, older core' => ['>=0.25.0', '0.24.3', false], + 'at least, exact core' => ['>=0.25.0', '0.25.0', true], + 'at least, newer core' => ['>=0.25.0', '0.26.1', true], + + // The version this project prints carries a v; manifests do not. + 'v prefix on the core version' => ['>=0.25.0', 'v0.25.0', true], + 'v prefix in the constraint' => ['>=v0.25.0', '0.25.0', true], + + // A bare version reads as "or newer" — an exact match would strand + // every module the day core is patched. + 'bare version, newer core' => ['0.25.0', '0.25.4', true], + 'bare version, older core' => ['0.25.0', '0.24.9', false], + + 'below' => ['<0.25.0', '0.24.3', true], + 'below, equal' => ['<0.25.0', '0.25.0', false], + 'exactly' => ['=0.25.0', '0.25.0', true], + 'exactly, patched' => ['=0.25.0', '0.25.1', false], + 'not' => ['!=0.25.0', '0.25.1', true], + + // Ordering is numeric, not lexical: 0.9 is older than 0.25. + 'two digit minor beats single digit' => ['>=0.25.0', '0.9.0', false], + + 'whitespace' => [' >= 0.25.0 ', '0.25.0', true], + 'short version' => ['>=0.25', '0.25.0', true], + ]; + } + + #[DataProvider('constraints')] + public function test_it_reads_a_constraint(?string $constraint, string $core, bool $expected): void + { + $this->assertSame($expected, CoreVersionConstraint::isSatisfied($constraint, $core)); + } + + /** @return array */ + public static function nonsense(): array + { + return [ + 'words' => ['latest'], + 'operator with no version' => ['>='], + 'two versions' => ['>=0.25.0 <0.26.0'], + 'caret is not supported' => ['^0.25.0'], + 'tilde is not supported' => ['~0.25.0'], + ]; + } + + #[DataProvider('nonsense')] + public function test_an_unreadable_constraint_is_refused_rather_than_guessed_at(string $constraint): void + { + // Silently treating a typo as "any version" is the exact failure this + // check exists to prevent, so it throws and the caller refuses. + $this->expectException(\InvalidArgumentException::class); + + CoreVersionConstraint::isSatisfied($constraint, '0.25.0'); + } +} From c453a0993913135638db64f16e9f22c085b7d881 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Sat, 26 Sep 2026 19:11:52 +0200 Subject: [PATCH 09/21] feat(extension): let a module reach the operator's station screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two seams, and deliberately no new mechanism. A display region on the workstation page, resolved the same way the admin forms resolve theirs, so a module that has something to say about the person at the machine has somewhere to say it. A module distributed as a ZIP cannot ship working React into a released install, so the contribution is data and core draws it — the existing Hook component already does, unchanged. The rule filter from the user and worker Form Requests, applied to starting and completing a step. Without a declared rule `validated()` drops an unknown key between the browser and the controller, silently; with one, a module's own field arrives and is validated server-side rather than trusted. The context names the operation as 'start' or 'complete' rather than the trait's create/edit wording: both are POSTs, and which one it is, is the whole distinction here. Nothing else turned out to be necessary. StepStarted and StepCompleted are already dispatched by BatchStepEventObserver, inside BatchService's transaction and on every path that moves a step, so a module hears about the work itself without core growing another hook for it. Not covered: the API step-completion request, which keeps its own rule set. The station flow this serves is the operator panel; widening it can wait for a module that needs it. --- CHANGELOG.md | 6 + .../Web/Operator/WorkstationController.php | 13 ++ .../Operator/CompleteBatchStepRequest.php | 19 +- .../Requests/Operator/StartStepRequest.php | 19 +- .../js/Pages/operator/Workstation.jsx | 11 + .../Extension/OperatorStationSeamTest.php | 213 ++++++++++++++++++ 6 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 backend/tests/Feature/Extension/OperatorStationSeamTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b2efb2551..1b96d0fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- **A module can reach the operator's station screen**: a display region on the workstation + page, and the same rule filter the user and worker forms use, so a module's own key + survives `validated()` when a step is started or completed. Nothing else was needed — + `StepStarted` and `StepCompleted` are already dispatched from the model observer, inside + the service transaction and on every path that moves a step. + - **A module's `requires_core` is now enforced.** Every manifest has carried it and nothing read it, so a module built against extension points this core does not have installed cleanly and then quietly did nothing. Installing or enabling one now says so instead, diff --git a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php index 53390fbdf..d7056b624 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php @@ -18,6 +18,9 @@ class WorkstationController extends Controller { + /** Where a module may contribute to the operator's station screen. */ + private const STATION_HOOK = 'display.operator.workstation.actor'; + /** * Workstation production view — flat table with inline quantity entry. */ @@ -142,7 +145,17 @@ public function index(Request $request) $machineStates = $this->machineStatesForLine((int) $lineId, $selectedWorkstation?->id); $machineStateOptions = WorkstationState::STATES; + // A region an installed module may contribute to — the station is where + // a module identifying the person at the machine has something to say, + // and a module cannot ship its own React into a released install. + // Empty on a community install, where the prop is `{}`. + $hooks = app(\App\Extension\HookRegistry::class)->renderMany( + [self::STATION_HOOK], + ['line' => $line, 'workstation' => $selectedWorkstation], + ); + return Inertia::render('operator/Workstation', compact( + 'hooks', 'workOrders', 'line', 'availableWeeks', 'weekFilter', 'search', 'issueTypes', 'allColumns', 'shifts', 'shiftEntries', 'today', 'trackingMode', 'qtyEditPolicy', 'qtyEditWindowMinutes', 'labelTemplates', diff --git a/backend/app/Http/Requests/Operator/CompleteBatchStepRequest.php b/backend/app/Http/Requests/Operator/CompleteBatchStepRequest.php index 5fd471552..f5290eb69 100644 --- a/backend/app/Http/Requests/Operator/CompleteBatchStepRequest.php +++ b/backend/app/Http/Requests/Operator/CompleteBatchStepRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Operator; +use App\Http\Requests\Concerns\AllowsModuleFields; use Illuminate\Foundation\Http\FormRequest; /** @@ -11,17 +12,31 @@ */ class CompleteBatchStepRequest extends FormRequest { + use AllowsModuleFields; + public function authorize(): bool { return true; } + protected function moduleFieldFilter(): string + { + return 'validation.operator.step'; + } + + protected function moduleFieldContext(): array + { + // 'start' / 'complete' rather than the trait's create/edit wording: + // both are POSTs, and which one it is, is the whole distinction here. + return ['action' => 'complete', 'step' => $this->route('batchStep')]; + } + public function rules(): array { - return [ + return $this->withModuleFields([ 'actual_elapsed_minutes' => ['nullable', 'integer', 'min:0'], 'actual_setup_minutes' => ['nullable', 'integer', 'min:0'], 'actual_run_minutes' => ['nullable', 'integer', 'min:0'], - ]; + ]); } } diff --git a/backend/app/Http/Requests/Operator/StartStepRequest.php b/backend/app/Http/Requests/Operator/StartStepRequest.php index 170612858..16d63bec0 100644 --- a/backend/app/Http/Requests/Operator/StartStepRequest.php +++ b/backend/app/Http/Requests/Operator/StartStepRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Operator; +use App\Http\Requests\Concerns\AllowsModuleFields; use Illuminate\Foundation\Http\FormRequest; /** @@ -14,19 +15,33 @@ */ class StartStepRequest extends FormRequest { + use AllowsModuleFields; + public function authorize(): bool { return true; } + protected function moduleFieldFilter(): string + { + return 'validation.operator.step'; + } + + protected function moduleFieldContext(): array + { + // 'start' / 'complete' rather than the trait's create/edit wording: + // both are POSTs, and which one it is, is the whole distinction here. + return ['action' => 'start', 'step' => $this->route('batchStep')]; + } + public function rules(): array { - return [ + return $this->withModuleFields([ 'picks' => ['nullable', 'array'], 'picks.*.material_id' => ['required', 'integer', 'exists:materials,id'], 'picks.*.lots' => ['required', 'array', 'min:1'], 'picks.*.lots.*.material_lot_id' => ['required', 'integer', 'exists:material_lots,id'], 'picks.*.lots.*.picked_qty' => ['required', 'numeric', 'gt:0'], - ]; + ]); } } diff --git a/backend/resources/js/Pages/operator/Workstation.jsx b/backend/resources/js/Pages/operator/Workstation.jsx index ff385769b..06a72a644 100644 --- a/backend/resources/js/Pages/operator/Workstation.jsx +++ b/backend/resources/js/Pages/operator/Workstation.jsx @@ -8,6 +8,7 @@ import LabelPrintMenu from '../../components/LabelPrintMenu'; import Tooltip from '../../components/Tooltip'; import DueCountdown, { SETTLED_STATUSES } from '../../components/DueCountdown'; import { formatDate, formatNumber } from '../../lib/i18n'; +import { Hook } from '../../lib/hooks'; // Geist White restyle: light-only v1 — former `dark:` variants removed. @@ -857,6 +858,7 @@ export default function Workstation() { machineStates = [], machineStateOptions = [], selectedWorkstation = null, + hooks = {}, } = usePage().props; const { visibleKeys, toggleColumn, resetColumns } = useVisibleColumns(allColumns, line?.id ?? 0); @@ -956,6 +958,15 @@ export default function Workstation() {
+ {/* Anything an installed module contributes to this screen. + Renders nothing on a community install. */} + + {/* Week filter */} {availableWeeks.length > 0 && (
diff --git a/backend/tests/Feature/Extension/OperatorStationSeamTest.php b/backend/tests/Feature/Extension/OperatorStationSeamTest.php new file mode 100644 index 000000000..b4bb4bd00 --- /dev/null +++ b/backend/tests/Feature/Extension/OperatorStationSeamTest.php @@ -0,0 +1,213 @@ +line = Line::factory()->create(); + } + + private function operator(): User + { + $operator = User::factory()->create(); + $operator->assignRole('Operator'); + $operator->lines()->attach($this->line->id); + + return $operator; + } + + private function actingAtStation(): User + { + $operator = $this->operator(); + $this->actingAs($operator)->withSession(['selected_line_id' => $this->line->id]); + + return $operator; + } + + /** @return list> */ + private function contributionsOn(\Illuminate\Testing\TestResponse $response): array + { + // Not via assertInertia's dot paths: a hook point's name contains dots, + // so every segment would be read as another level of nesting. + return $response->viewData('page')['props']['hooks'][self::STATION] ?? []; + } + + private function pendingStep(): BatchStep + { + $workOrder = WorkOrder::factory()->create(['line_id' => $this->line->id]); + $batch = Batch::factory()->create(['work_order_id' => $workOrder->id]); + + return BatchStep::factory()->create([ + 'batch_id' => $batch->id, + 'status' => BatchStep::STATUS_PENDING, + ]); + } + + public function test_a_community_install_is_sent_no_contributions(): void + { + $this->actingAtStation(); + + $response = $this->get(route('operator.workstation'))->assertOk(); + + $this->assertSame([], $response->viewData('page')['props']['hooks']); + } + + public function test_a_module_can_put_something_on_the_station_screen(): void + { + app(HookRegistry::class)->listen(self::STATION, [ + 'title' => 'Nobody identified', + 'body' => 'Present a badge to the reader.', + ]); + + $this->actingAtStation(); + + $response = $this->get(route('operator.workstation'))->assertOk(); + + $this->assertSame([[ + 'title' => 'Nobody identified', + 'body' => 'Present a badge to the reader.', + ]], $this->contributionsOn($response)); + } + + public function test_the_contribution_is_told_which_line_and_station(): void + { + $seen = null; + app(HookRegistry::class)->listen(self::STATION, function ($ctx) use (&$seen) { + $seen = $ctx; + + return ['title' => 'Present']; + }); + + $this->actingAtStation(); + $this->get(route('operator.workstation'))->assertOk(); + + $this->assertSame($this->line->id, $seen['line']->id); + $this->assertArrayHasKey('workstation', $seen); + } + + public function test_a_contribution_cannot_smuggle_anything_past_the_whitelist(): void + { + app(HookRegistry::class)->listen(self::STATION, [ + 'title' => 'Present', + 'onClick' => 'alert(1)', + 'dangerouslySetInnerHTML' => ['__html' => ''], + ]); + + $this->actingAtStation(); + + $response = $this->get(route('operator.workstation'))->assertOk(); + + $this->assertSame([['title' => 'Present']], $this->contributionsOn($response)); + } + + public function test_a_module_key_is_dropped_from_a_step_start_without_a_rule(): void + { + // The starting point: no rule, no key. Nothing objects; the value simply + // never reaches the controller. + $step = $this->pendingStep(); + $this->actingAtStation(); + + $this->post("/operator/batch-step/{$step->id}/start", ['module_example_code' => 'ABC123']) + ->assertSessionHasNoErrors(); + } + + public function test_a_module_rule_reaches_the_step_start(): void + { + app(FilterRegistry::class)->addFilter( + self::RULES, + fn ($rules) => $rules + ['module_example_code' => ['required', 'string', 'max:8']], + ); + + $step = $this->pendingStep(); + $this->actingAtStation(); + + // Enforced, which is only possible if the key reached the validator. + $this->post("/operator/batch-step/{$step->id}/start", ['module_example_code' => str_repeat('X', 40)]) + ->assertSessionHasErrors('module_example_code'); + } + + public function test_the_rule_filter_is_told_which_operation_it_is(): void + { + $actions = []; + app(FilterRegistry::class)->addFilter(self::RULES, function ($rules, $context) use (&$actions) { + $actions[] = $context['action']; + + return $rules; + }); + + $step = $this->pendingStep(); + $this->actingAtStation(); + + $this->post("/operator/batch-step/{$step->id}/start", []); + $this->post("/operator/batch-step/{$step->id}/complete", []); + + $this->assertSame(['start', 'complete'], $actions); + } + + public function test_the_rule_filter_carries_the_step(): void + { + $context = null; + app(FilterRegistry::class)->addFilter(self::RULES, function ($rules, $ctx) use (&$context) { + $context = $ctx; + + return $rules; + }); + + $step = $this->pendingStep(); + $this->actingAtStation(); + $this->post("/operator/batch-step/{$step->id}/start", []); + + $this->assertSame($step->id, $context['step']?->id); + } + + public function test_core_rules_still_apply_with_a_module_present(): void + { + app(FilterRegistry::class)->addFilter( + self::RULES, + fn ($rules) => $rules + ['module_example_code' => ['nullable', 'string']], + ); + + $step = $this->pendingStep(); + $this->actingAtStation(); + + $this->post("/operator/batch-step/{$step->id}/start", [ + 'picks' => [['material_id' => 999999, 'lots' => [['material_lot_id' => 1, 'picked_qty' => 1]]]], + ])->assertSessionHasErrors('picks.0.material_id'); + } +} From abe9d3b74f000748cc33e7fd915b0f4faa9be93f Mon Sep 17 00:00:00 2001 From: JanKolo04 Date: Thu, 17 Sep 2026 22:17:06 +0200 Subject: [PATCH 10/21] feat(modules): operator-panel tabs, module translations, import entities, ModuleTestCase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four generic seams a module reaches the product through, none naming a module: - MenuRegistry::addOperatorItem() renders a module's screen as a tab on the operator top bar (Inertia link, path-prefix highlight), bridged as moduleNav.operator. - modules//lang/.json is merged under core's translations at bootstrap, so a module ships its own strings without touching lang/. - ImportRegistry::entities() runs through the import.entities filter, so a module can add an entity to Admin → Import; the instance cache is rebuilt when the list changes. - Tests\Support\ModuleTestCase registers a module's provider per test and migrates its directory inside the test transaction. Sidebar entries a module contributed are tinted with the accent colour, and now highlight on their own pages: modules register absolute URLs, which the path-based active check never matched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ej2yusocapyJ9KsNcDv63v --- CHANGELOG.md | 1 + HOOKS.md | 57 ++++++++++ .../Http/Middleware/HandleInertiaRequests.php | 3 + backend/app/Import/ImportRegistry.php | 26 ++++- backend/app/Services/MenuRegistry.php | 47 ++++++++ backend/resources/js/layouts/AppLayout.jsx | 26 ++++- .../resources/js/layouts/OperatorLayout.jsx | 17 ++- backend/resources/js/lib/i18n.js | 15 ++- backend/resources/js/lib/i18n.modules.test.js | 19 ++++ .../Feature/Extension/ModuleSeamsTest.php | 106 ++++++++++++++++++ backend/tests/Support/ModuleTestCase.php | 57 ++++++++++ 11 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 backend/resources/js/lib/i18n.modules.test.js create mode 100644 backend/tests/Feature/Extension/ModuleSeamsTest.php create mode 100644 backend/tests/Support/ModuleTestCase.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b96d0fcb..28922eccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). A module distributed as a ZIP cannot ship working React into a released install — the page globs are expanded when core is built — so contributing data that core renders is the only arrangement that works at all. +- Module seams: `MenuRegistry::addOperatorItem()` puts a module's screen on the operator top bar as a tab; `modules//lang/.json` strings are merged into the frontend translations; `ImportRegistry` accepts module importers through the `import.entities` filter; `Tests\Support\ModuleTestCase` migrates a module's tables inside the test transaction. Module-contributed nav entries are tinted with the accent colour and highlight on their own pages (they registered absolute URLs, which the path-based active check never matched). ### Fixed diff --git a/HOOKS.md b/HOOKS.md index 53d9b27f1..52a6364a4 100644 --- a/HOOKS.md +++ b/HOOKS.md @@ -227,6 +227,63 @@ $menu->addGroupItem('yourmod', 'Overview', url('/modules/your-module'), order: 1 Resolve URLs with `url()` (not `route()`) so registration never depends on route load order or a cached route table. +Every entry a module contributes is tinted with the accent colour in the sidebar, +and lights up as active on its own pages (the registered URL is matched by path). + +### Operator panel tabs + +A module that ships an operator screen registers it as a tab on the operator +top bar, next to Queue / Workstation: + +```php +// label, url, order (built-in tabs are 10 and 20), optional path prefix that +// keeps the tab highlighted (defaults to the link's own path). +$menu->addOperatorItem('Team', url('/operator/team'), order: 30); +``` + +Operator tabs are Inertia links — the page behind them is a React page under the +module's `resources/js/Pages/`. They arrive in the browser as `moduleNav.operator` +and render tinted like sidebar entries. + +### Translations + +A module's own strings live in `modules//lang/.json` (the same +source-string-keyed shape as core's `lang/*.json`). The frontend merges them +under the core file at bootstrap — a module can add strings, never redefine a +core one — and the provider loads the same directory for PHP: + +```php +$this->loadJsonTranslationsFrom(__DIR__.'/../lang'); +``` + +### Import entities + +`ImportRegistry` (Admin → Import) accepts importers from modules through the +`import.entities` filter: + +```php +app(FilterRegistry::class)->addFilter('import.entities', fn ($e) => [...$e, MyImporter::class]); +``` + +`MyImporter` extends `App\Import\AbstractEntityImporter`; the screen, the queued +job, the sample file and the routes pick it up. + +### Testing a module + +`Tests\Support\ModuleTestCase` registers the module's provider on each test's +fresh application and migrates the module's directory inside the test +transaction (the suite's `migrate:fresh` runs before any provider boots, so a +module's tables are never part of that schema): + +```php +class MyModuleTest extends \Tests\Support\ModuleTestCase +{ + protected string $module = 'MyModule'; + protected string $provider = \Modules\MyModule\Providers\MyModuleServiceProvider::class; + protected string $probeTable = 'my_module_things'; +} +``` + ## 3. Dashboard widget hooks `App\Services\WidgetRegistry` lets a module add cards to the admin dashboard. diff --git a/backend/app/Http/Middleware/HandleInertiaRequests.php b/backend/app/Http/Middleware/HandleInertiaRequests.php index 9eec10d18..6a287d3c0 100644 --- a/backend/app/Http/Middleware/HandleInertiaRequests.php +++ b/backend/app/Http/Middleware/HandleInertiaRequests.php @@ -60,6 +60,9 @@ public function share(Request $request): array 'moduleNav' => [ 'items' => fn () => app(\App\Services\MenuRegistry::class)->getAllItems(), 'groups' => fn () => app(\App\Services\MenuRegistry::class)->getGroups(), + // operator: [{label,url,order,prefix}] extra tabs on the operator + // panel's top bar (OperatorLayout), Inertia links. + 'operator' => fn () => app(\App\Services\MenuRegistry::class)->getOperatorItems(), ], 'csrf_token' => fn () => csrf_token(), 'appVersion' => fn () => config('version.current'), diff --git a/backend/app/Import/ImportRegistry.php b/backend/app/Import/ImportRegistry.php index f4f466c46..de529819c 100644 --- a/backend/app/Import/ImportRegistry.php +++ b/backend/app/Import/ImportRegistry.php @@ -35,13 +35,35 @@ class ImportRegistry /** @var array|null key => instance */ private ?array $instances = null; + /** The class list the cached instances were built from. */ + private array $instancesFor = []; + + /** + * Every importer, including any an installed module registered. + * + * The constant stays the definition; this is the one seam through which a + * module adds an entity to the Import screen, from its provider's boot(): + * app(FilterRegistry::class)->addFilter('import.entities', fn ($e) => [...$e, MyImporter::class]); + * + * @return list> + */ + public static function entities(): array + { + return app(\App\Extension\FilterRegistry::class)->filter('import.entities', self::ENTITIES); + } + /** @return array key => importer */ public function all(): array { - if ($this->instances === null) { + // Rebuilt whenever the entity list changes — a module registered after + // the first call (tests, a late-booting provider) must still appear. + $entities = self::entities(); + + if ($this->instances === null || $this->instancesFor !== $entities) { $this->instances = []; + $this->instancesFor = $entities; - foreach (self::ENTITIES as $class) { + foreach ($entities as $class) { $importer = app($class); $this->instances[$importer->key()] = $importer; } diff --git a/backend/app/Services/MenuRegistry.php b/backend/app/Services/MenuRegistry.php index 15d9a776b..a9438f1e4 100644 --- a/backend/app/Services/MenuRegistry.php +++ b/backend/app/Services/MenuRegistry.php @@ -59,6 +59,9 @@ class MenuRegistry /** Custom top-level dropdown groups. */ private array $groups = []; + /** @var list */ + private array $operatorItems = []; + // ------------------------------------------------------------------------- // Built-in group injection // ------------------------------------------------------------------------- @@ -180,4 +183,48 @@ public function getGroups(): array return $groups; } + + // ------------------------------------------------------------------------- + // Operator (shop-floor panel) tabs + // ------------------------------------------------------------------------- + + /** + * Add a tab to the operator panel's top bar, next to Queue / Workstation. + * + * The operator chrome is a different surface from the admin sidebar: a + * handful of big touch targets on a tablet, not a tree of dropdowns. A + * module that ships an operator screen — a waste register, a team clock-in — + * registers it here and it renders as one more tab, active while the + * browser is anywhere under `$prefix` (defaults to the link's own path). + * + * Module operator pages are React/Inertia pages, so the tab is an Inertia + * link, not a full page load. + * + * @param string $label Tab text + * @param string $url Resolved URL (call route() or url() in your ServiceProvider) + * @param int $order Sort weight — built-in tabs are 10 (Queue) and 20 (Workstation) + * @param string|null $prefix Path prefix that keeps the tab highlighted + */ + public function addOperatorItem(string $label, string $url, int $order = 50, ?string $prefix = null): void + { + $this->operatorItems[] = [ + 'label' => $label, + 'url' => $url, + 'order' => $order, + 'prefix' => $prefix ?? (parse_url($url, PHP_URL_PATH) ?: $url), + ]; + } + + /** + * Operator tabs sorted by order. + * + * @return list + */ + public function getOperatorItems(): array + { + $items = $this->operatorItems; + usort($items, fn ($a, $b) => $a['order'] <=> $b['order']); + + return $items; + } } diff --git a/backend/resources/js/layouts/AppLayout.jsx b/backend/resources/js/layouts/AppLayout.jsx index 545b06e91..82ca903a8 100644 --- a/backend/resources/js/layouts/AppLayout.jsx +++ b/backend/resources/js/layouts/AppLayout.jsx @@ -20,6 +20,19 @@ import { Breadcrumbs, Icon as UiIcon } from '@openmes/ui'; // dropdown key `adminGroup`. const MODULE_GROUP_ALIASES = { admin: 'adminGroup' }; +/** '/admin/bakery/routings' from an absolute or relative URL, query dropped. */ +function pathOf(url) { + try { + return new URL(url, typeof window !== 'undefined' ? window.location.origin : 'http://localhost').pathname.replace(/\/+$/, '') || '/'; + } catch { + return String(url ?? '').split('?')[0]; + } +} + +// Entries an installed module contributed are tinted so it is visible at a +// glance which screens are core and which came with a module. +export const MODULE_TINT = 'bg-om-accent-bg border-l-2 border-om-accent'; + // Module pages are legacy server-rendered (Blade), not Inertia components, so // their links must trigger a full navigation (`external`) — an Inertia // would fetch JSON for a non-Inertia route and fail. @@ -31,7 +44,10 @@ function moduleItemToChild(item) { return { label: item.label, href: item.url, - match: [item.url], + // Modules register absolute URLs (url()/route()), but the active check + // compares against the page path — match on the path or the entry never + // lights up on its own screen. + match: [pathOf(item.url)], external: true, order: item.order, badge: item.badge, @@ -206,7 +222,7 @@ function mergeModuleNav(moduleNav) { // A module may declare a group that is really a single screen: it // names a url and ships no entries, and then renders flat. href: g.url ?? undefined, - match: g.url ? [g.url, ...groupMatch([], children)] : groupMatch([], children), + match: g.url ? [pathOf(g.url), ...groupMatch([], children)] : groupMatch([], children), children, }; }); @@ -951,7 +967,8 @@ function NavGroup({ group, path, collapsed, showLabels, showTab = () => true }) className={`flex items-center gap-3 w-full px-3 py-2.5 rounded-om-sm transition-colors text-om-faint hover:bg-om-chip hover:text-om-ink ${collapsed && !showLabels ? 'justify-center !px-0' : ''} - ${groupActive && showLabels ? 'text-om-ink' : ''}`} + ${groupActive && showLabels ? 'text-om-ink' : ''} + ${group.moduleGroup ? MODULE_TINT : ''}`} > {group.lucide ? @@ -1064,7 +1081,8 @@ function ChildLink({ child, path, dot, hideBadge = false }) { } const className = `flex items-center gap-2 px-2 py-1.5 rounded-om-sm text-[13px] transition-colors - ${active ? 'bg-om-ink text-om-on-ink font-medium' : 'text-om-muted hover:bg-om-chip hover:text-om-ink'}`; + ${active ? 'bg-om-ink text-om-on-ink font-medium' : 'text-om-muted hover:bg-om-chip hover:text-om-ink'} + ${child.external && !active ? MODULE_TINT : ''}`; // Module (legacy Blade) target — plain anchor for a full page load. if (child.external) { diff --git a/backend/resources/js/layouts/OperatorLayout.jsx b/backend/resources/js/layouts/OperatorLayout.jsx index a9469caa6..bda14d6c8 100644 --- a/backend/resources/js/layouts/OperatorLayout.jsx +++ b/backend/resources/js/layouts/OperatorLayout.jsx @@ -17,7 +17,9 @@ import { __ } from '../lib/i18n'; * Geist White restyle: light-only v1 — former `dark:` classes removed. */ export default function OperatorLayout({ children }) { - const { auth, line, selectedWorkstation, csrf_token } = usePage().props; + const { auth, line, selectedWorkstation, csrf_token, moduleNav } = usePage().props; + // Tabs an enabled module registered via MenuRegistry::addOperatorItem(). + const moduleTabs = moduleNav?.operator ?? []; const path = typeof window !== 'undefined' ? window.location.pathname : ''; const isActive = (prefix) => path === prefix || path.startsWith(prefix); @@ -48,6 +50,11 @@ export default function OperatorLayout({ children }) { {__('Workstation')} + {moduleTabs.map((tab) => ( + + {__(tab.label)} + + ))} {children} diff --git a/backend/resources/js/lib/i18n.js b/backend/resources/js/lib/i18n.js index 32e9879bf..88fa64be4 100644 --- a/backend/resources/js/lib/i18n.js +++ b/backend/resources/js/lib/i18n.js @@ -17,6 +17,10 @@ // Lazy glob → one dynamic-import chunk per locale file. const localeFiles = import.meta.glob('../../../lang/*.json'); +// A module ships its own strings under modules//lang/.json (the +// same source-string-keyed shape). They are merged under the core file, so a +// module can never redefine a core string — only add its own. +const moduleLocaleFiles = import.meta.glob('../../../modules/*/lang/*.json'); let messages = {}; let activeLocale = 'en'; @@ -27,7 +31,16 @@ let activeTimezone; /** Load (and activate) a locale's messages. Call once before the first render. */ export async function loadLocale(locale) { const loader = localeFiles[`../../../lang/${locale}.json`]; - messages = loader ? (await loader()).default ?? {} : {}; + const core = loader ? (await loader()).default ?? {} : {}; + + const suffix = `/lang/${locale}.json`; + const fromModules = await Promise.all( + Object.entries(moduleLocaleFiles) + .filter(([path]) => path.endsWith(suffix)) + .map(([, load]) => load().then((m) => m.default ?? {})), + ); + + messages = Object.assign({}, ...fromModules, core); activeLocale = locale; return messages; } diff --git a/backend/resources/js/lib/i18n.modules.test.js b/backend/resources/js/lib/i18n.modules.test.js new file mode 100644 index 000000000..1ad10a0ac --- /dev/null +++ b/backend/resources/js/lib/i18n.modules.test.js @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +// Vitest resolves import.meta.glob like Vite does, so this exercises the real +// module-lang merge in i18n.js against whatever modules/ holds — which, in +// core's own checkout, is only the reference modules with no lang/ directory. +import { __, loadLocale } from './i18n'; + +describe('module translations', () => { + it('loads a locale with or without module lang files', async () => { + const messages = await loadLocale('pl'); + expect(typeof messages).toBe('object'); + expect(__('Dashboard')).toBeTypeOf('string'); + }); + + it('falls back to the key for an unknown string', async () => { + await loadLocale('en'); + expect(__('A string no file defines')).toBe('A string no file defines'); + }); +}); diff --git a/backend/tests/Feature/Extension/ModuleSeamsTest.php b/backend/tests/Feature/Extension/ModuleSeamsTest.php new file mode 100644 index 000000000..2761e4654 --- /dev/null +++ b/backend/tests/Feature/Extension/ModuleSeamsTest.php @@ -0,0 +1,106 @@ +assertSame([], $menu->getOperatorItems()); + + $menu->addOperatorItem('Waste', url('/operator/waste'), order: 50); + $menu->addOperatorItem('Team', url('/operator/team?x=1'), order: 30); + $menu->addOperatorItem('Docs', url('/operator/docs'), order: 40, prefix: '/operator/doc'); + + $items = $menu->getOperatorItems(); + $this->assertSame(['Team', 'Docs', 'Waste'], array_column($items, 'label')); + $this->assertSame('/operator/team', $items[0]['prefix']); + $this->assertSame('/operator/doc', $items[1]['prefix']); + + $this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class); + $operator = User::factory()->create(); + $operator->assignRole('Operator'); + + $this->actingAs($operator) + ->get('/operator/select-line') + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('moduleNav.operator.0.label', 'Team') + ->where('moduleNav.operator.0.url', url('/operator/team?x=1'))); + } + + public function test_a_module_importer_joins_the_registry_through_the_filter(): void + { + $registry = app(ImportRegistry::class); + $before = $registry->keys(); + $this->assertNotContains('seam_things', $before); + + app(FilterRegistry::class)->addFilter('import.entities', fn (array $e) => [...$e, SeamThingImporter::class]); + + // Already-built instances are rebuilt when the entity list changes. + $this->assertContains('seam_things', $registry->keys()); + $this->assertSame('seam-things', $registry->fromSlug('seam-things')?->slug()); + $this->assertArrayHasKey('seam_things', $registry->forSection('admin')); + $this->assertArrayNotHasKey('seam_things', $registry->forSection('supervisor')); + $this->assertSame(count($before) + 1, count($registry->keys())); + } +} + +class SeamThingImporter extends AbstractEntityImporter +{ + public function key(): string + { + return 'seam_things'; + } + + public function label(): string + { + return 'Seam things'; + } + + public function description(): string + { + return 'A test-only entity.'; + } + + public function fields(): array + { + return ['name' => ['label' => 'Name', 'required' => true, 'type' => 'text']]; + } + + public function options(): array + { + return []; + } + + public function optionRules(): array + { + return []; + } + + public function sample(): array + { + return ['headers' => ['name'], 'rows' => [['a']]]; + } + + public function import(array $rows, array $options): array + { + return ['imported' => count($rows), 'updated' => 0, 'skipped' => 0, 'errors' => []]; + } +} diff --git a/backend/tests/Support/ModuleTestCase.php b/backend/tests/Support/ModuleTestCase.php new file mode 100644 index 000000000..11c383a8f --- /dev/null +++ b/backend/tests/Support/ModuleTestCase.php @@ -0,0 +1,57 @@ +app->register($this->provider); + app('router')->getRoutes()->refreshNameLookups(); + + // Console commands a provider registers arrive through an Artisan + // "starting" callback; the test kernel already built its Artisan + // instance, so drop it and the next call rebuilds it with the module's. + $this->app[\Illuminate\Contracts\Console\Kernel::class]->setArtisan(null); + } + + protected function afterRefreshingDatabase(): void + { + if (Schema::hasTable($this->probeTable)) { + return; + } + + $path = app(ModuleManager::class)->migrationsPath($this->module); + + Artisan::call('migrate', ['--path' => $path, '--realpath' => true, '--force' => true]); + } +} From a2de4ef41d35a73e585b420610ee347f64efae75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Ko=C5=82odziej?= <76879087+JanKolo04@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:29:28 +0200 Subject: [PATCH 11/21] feat: hooks in core fo modules --- HOOKS.md | 84 ++ .../Web/Operator/WorkOrderController.php | 16 +- .../Web/Operator/WorkstationController.php | 18 +- .../Controllers/Web/SettingsController.php | 3 + .../Http/Middleware/HandleInertiaRequests.php | 30 + backend/app/Services/MenuRegistry.php | 9 +- backend/resources/js/Pages/operator/Queue.jsx | 5 +- .../js/Pages/operator/WorkOrderDetail.jsx | 23 +- .../js/Pages/operator/Workstation.jsx | 32 +- .../resources/js/Pages/settings/System.jsx | 1030 +++++++++-------- .../resources/js/components/QuantityField.jsx | 34 + .../resources/js/layouts/OperatorLayout.jsx | 51 +- backend/resources/js/lib/hooks.jsx | 5 +- backend/routes/web.php | 16 +- .../Extension/OperatorPanelSeamsTest.php | 229 ++++ 15 files changed, 1030 insertions(+), 555 deletions(-) create mode 100644 backend/resources/js/components/QuantityField.jsx create mode 100644 backend/tests/Feature/Extension/OperatorPanelSeamsTest.php diff --git a/HOOKS.md b/HOOKS.md index 52a6364a4..e0cd83e34 100644 --- a/HOOKS.md +++ b/HOOKS.md @@ -29,6 +29,7 @@ Three reference modules ship in the repo (all disabled by default): - [Scheduling hook — `WorkOrderScheduled`](#scheduling-hook--workorderscheduled) - [2. Menu hooks](#2-menu-hooks) - [3. Dashboard widget hooks](#3-dashboard-widget-hooks) +- [4. Page display hooks and filters](#4-page-display-hooks-and-filters) - [Enabling a module](#enabling-a-module) - [Best practices](#best-practices) - [Complete hook reference](#complete-hook-reference) @@ -308,6 +309,81 @@ KPIs; `main` renders as a full-width card at the bottom of the dashboard column. --- +## 4. Page display hooks and filters + +`App\Extension\HookRegistry` carries contributions to named points on a page; the +controller that owns the page resolves its points with `renderMany()` and hands them +over as the `hooks` prop, and the page renders `` +from `resources/js/lib/hooks.jsx`. `App\Extension\FilterRegistry` lets a module +change a value core computed. With no module listening, a page is sent `{}` and every +filter returns its default — a community install renders exactly what it did before. + +A point that **replaces** a core control (marked below) checks `hasHook()` first and +skips its own control when a module contributed. + +```php +app(HookRegistry::class)->listen('display.operator.work_order.sections', fn (array $ctx) => [ + 'title' => 'Recipe vs weighed', + 'body' => Recipe::summary($ctx['workOrderId']), +]); + +app(FilterRegistry::class)->addFilter('operator.can_logout', fn (bool $can, array $ctx) => $can && ! Panel::isShared($ctx['user'])); +``` + +> A `component` (`ext:/`, resolved under +> `modules//resources/js/Components/`) only exists in a build that contained +> the module. A module installed from a ZIP into a released install must contribute +> the plain card fields instead. + +### Display hooks + +``` +display.operator.workstation.shift_cell context: entry, workOrder, shift, canCorrect (replaces the whole shift cell) +display.operator.work_order.sections context: workOrder (rendered after the BOM section) +display.operator.quantity_field props: value, onChange, variant (replaces the operator's number input, page-wide) +display.operator.layout (no context) (rendered at the top of every operator screen) +display.settings.system.tabs contribution: slot, title, component (one tab each on Settings → System) +``` + +The PHP-side context a listener receives: + +| Hook | Resolved by | PHP context | +|---|---|---| +| `display.operator.workstation.shift_cell` | `Operator\WorkstationController::index` | `line`, `workstation`, `lineId`, `workstationId` | +| `display.operator.work_order.sections` | `Operator\WorkOrderController::show` | `workOrderId`, `workstationId` | +| `display.operator.quantity_field` | `WorkstationController::index`, `WorkOrderController::show` + `::queue` | same as the page's other points | +| `display.operator.layout` | `HandleInertiaRequests` (shared `operatorHooks`) | `user` | +| `display.settings.system.tabs` | `SettingsController::showSystemSettings` | — | + +Notes: + +- **`quantity_field`** — `QuantityField.jsx` renders the first contribution's + component with `value`, `onChange(value)` (the value, not an event), `variant` + (`big` for a modal's single field, `compact` inline) and every other input prop + (`min`, `max`, `step`, `aria-label`, …) passed through. +- **`settings.system.tabs`** — each contribution becomes a tab with value + `ext-` (linkable as `?tab=ext-`) labelled `title`. Its component + renders outside the core settings form, so core's Save button is not shown + there: the module saves through its own route. +- **`operator.layout`** is resolved on every Inertia response, like any shared + prop — a listener should return `null` cheaply where it has nothing to show. + +### Filters + +``` +operator.tabs list of {key, label, url, prefixes} the operator top bar's core tabs +operator.module_tabs list of {label, url, order, prefix} module tabs, per request +operator.can_logout bool (default true) whether the operator chrome shows its logout button +``` + +`operator.tabs` and `operator.can_logout` receive `['user' => $user]` as context. +`operator.module_tabs` runs over what `addOperatorItem()` registered, on every +request, so a module can show a tab only where it applies without re-registering. +`operator.can_logout` hides the button only — a module that forbids logout must +still refuse the `POST /logout` itself. + +--- + ## Enabling a module 1. **Admin → Modules → your module → Enable** (or add its name to the @@ -368,6 +444,14 @@ class SyncToErp implements ShouldQueue **Widgets** (`App\Services\WidgetRegistry`): `register` — zones `kpi`, `main`, `sidebar`. +**Display hooks** (`App\Extension\HookRegistry`): `display.operator.workstation.actor`, +`display.operator.workstation.shift_cell`, `display.operator.work_order.sections`, +`display.operator.quantity_field`, `display.operator.layout`, +`display.settings.system.tabs`. + +**Filters** (`App\Extension\FilterRegistry`): `operator.tabs`, +`operator.module_tabs`, `operator.can_logout`, `import.entities`. + --- ## Support diff --git a/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php b/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php index 472a47da2..fb65ba400 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php @@ -108,7 +108,14 @@ public function queue(Request $request) ->latest('started_at') ->first(); + // The operator's number inputs, when a module replaces them (a keypad). + $hooks = app(\App\Extension\HookRegistry::class)->renderMany( + ['display.operator.quantity_field'], + ['lineId' => (int) $lineId, 'workstationId' => $selectedWorkstation?->id], + ); + return Inertia::render('operator/Queue', compact( + 'hooks', 'activeWorkOrders', 'completedWorkOrders', 'line', 'selectedWorkstation', 'lineStatuses', 'issueTypes', 'workflowMode', 'doneStatusIds', 'trackingMode', 'workstationQueue', 'workstationNotStarted', 'lineWorkstations', @@ -408,6 +415,13 @@ public function show(Request $request, WorkOrder $workOrder) ->resolve($request, (int) $workOrder->line_id, allowOtherLines: (bool) json_decode(\Illuminate\Support\Facades\DB::table('system_settings')->where('key', 'workstation_routing_enabled')->value('value') ?? 'false', true)); $selectedWorkstation = $selectedWorkstation?->only(['id', 'name', 'code']); - return Inertia::render('operator/WorkOrderDetail', compact('workOrder', 'issueTypes', 'scrapReasons', 'workstations', 'defaultWorkstationId', 'line', 'labelTemplates', 'processPhotos', 'stepPhotos', 'stepMedia', 'stepChecklists', 'stepOutputs', 'issueCustomFields', 'engineeringDocuments', 'materialShortages', 'flowMode', 'selectedWorkstation')); + // Regions an installed module may contribute to: extra sections after the + // BOM, and a replacement for the operator's number inputs. `{}` when none. + $hooks = app(\App\Extension\HookRegistry::class)->renderMany( + ['display.operator.work_order.sections', 'display.operator.quantity_field'], + ['workOrderId' => $workOrder->id, 'workstationId' => $selectedWorkstation['id'] ?? null], + ); + + return Inertia::render('operator/WorkOrderDetail', compact('hooks', 'workOrder', 'issueTypes', 'scrapReasons', 'workstations', 'defaultWorkstationId', 'line', 'labelTemplates', 'processPhotos', 'stepPhotos', 'stepMedia', 'stepChecklists', 'stepOutputs', 'issueCustomFields', 'engineeringDocuments', 'materialShortages', 'flowMode', 'selectedWorkstation')); } } diff --git a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php index d7056b624..83c331e34 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php @@ -21,6 +21,12 @@ class WorkstationController extends Controller /** Where a module may contribute to the operator's station screen. */ private const STATION_HOOK = 'display.operator.workstation.actor'; + /** Replaces a whole shift cell (quantity input + correction link). */ + private const SHIFT_CELL_HOOK = 'display.operator.workstation.shift_cell'; + + /** Replaces every operator number input on the page. */ + private const QUANTITY_FIELD_HOOK = 'display.operator.quantity_field'; + /** * Workstation production view — flat table with inline quantity entry. */ @@ -149,9 +155,17 @@ public function index(Request $request) // a module identifying the person at the machine has something to say, // and a module cannot ship its own React into a released install. // Empty on a community install, where the prop is `{}`. + // + // The shift cell and quantity field points REPLACE a core control when a + // module contributes; the page checks hasHook() and otherwise draws its own. $hooks = app(\App\Extension\HookRegistry::class)->renderMany( - [self::STATION_HOOK], - ['line' => $line, 'workstation' => $selectedWorkstation], + [self::STATION_HOOK, self::SHIFT_CELL_HOOK, self::QUANTITY_FIELD_HOOK], + [ + 'line' => $line, + 'workstation' => $selectedWorkstation, + 'lineId' => (int) $lineId, + 'workstationId' => $selectedWorkstation?->id, + ], ); return Inertia::render('operator/Workstation', compact( diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index bfc539cff..4e0848747 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -193,6 +193,9 @@ public function showSystemSettings() DB::table('system_settings')->where('key', 'sample_data_loaded')->value('value') ?? 'null', true, ), + // Tabs a module adds to this page: {slot, title, component} each. + // The module's component saves through its own route. `{}` when none. + 'hooks' => app(\App\Extension\HookRegistry::class)->renderMany(['display.settings.system.tabs']), ]); } diff --git a/backend/app/Http/Middleware/HandleInertiaRequests.php b/backend/app/Http/Middleware/HandleInertiaRequests.php index 6a287d3c0..f8ee8486c 100644 --- a/backend/app/Http/Middleware/HandleInertiaRequests.php +++ b/backend/app/Http/Middleware/HandleInertiaRequests.php @@ -64,6 +64,19 @@ public function share(Request $request): array // panel's top bar (OperatorLayout), Inertia links. 'operator' => fn () => app(\App\Services\MenuRegistry::class)->getOperatorItems(), ], + // Operator chrome (OperatorLayout). Each is a seam a module can bend + // without core knowing which module, or why: + // operatorTabs: the top bar's own tabs, through filter `operator.tabs` + // operatorCanLogout: whether the logout button shows, filter `operator.can_logout` + // (UX only — a module that forbids logout refuses the POST itself) + // operatorHooks: display hook `display.operator.layout`, rendered at the + // top of every operator screen + // With no module listening these are the defaults, `true` and `{}`. + 'operatorTabs' => fn () => $this->operatorTabs($user), + 'operatorCanLogout' => fn () => (bool) app(\App\Extension\FilterRegistry::class) + ->filter('operator.can_logout', true, ['user' => $user]), + 'operatorHooks' => fn () => app(\App\Extension\HookRegistry::class) + ->renderMany(['display.operator.layout'], ['user' => $user]), 'csrf_token' => fn () => csrf_token(), 'appVersion' => fn () => config('version.current'), // i18n: the active locale + the switcher's options. The frontend @@ -117,6 +130,23 @@ private function accessibleTabLinks($user): array ); } + /** + * The operator top bar's core tabs, as data a module may filter. Labels are + * English keys; the frontend translates them. A tab is active while the path + * starts with any of its `prefixes`. + * + * @return list}> + */ + private function operatorTabs($user): array + { + $tabs = [ + ['key' => 'queue', 'label' => 'Queue', 'url' => '/operator/queue', 'prefixes' => ['/operator/queue', '/operator/work-order']], + ['key' => 'workstation', 'label' => 'Workstation', 'url' => '/operator/workstation', 'prefixes' => ['/operator/workstation']], + ]; + + return array_values(app(\App\Extension\FilterRegistry::class)->filter('operator.tabs', $tabs, ['user' => $user])); + } + private function alertCount($user): int { if (! $user || ! $user->hasAnyRole(['Admin', 'Supervisor'])) { diff --git a/backend/app/Services/MenuRegistry.php b/backend/app/Services/MenuRegistry.php index a9438f1e4..57ba24cf1 100644 --- a/backend/app/Services/MenuRegistry.php +++ b/backend/app/Services/MenuRegistry.php @@ -216,13 +216,18 @@ public function addOperatorItem(string $label, string $url, int $order = 50, ?st } /** - * Operator tabs sorted by order. + * Operator tabs sorted by order, after filter `operator.module_tabs`. + * + * Registration happens once at boot; the filter runs per request, so a module + * can decide which of the tabs this viewer sees right now (per station, per + * who is signed in) without re-registering anything. * * @return list */ public function getOperatorItems(): array { - $items = $this->operatorItems; + $items = app(\App\Extension\FilterRegistry::class)->filter('operator.module_tabs', $this->operatorItems); + $items = array_values($items); usort($items, fn ($a, $b) => $a['order'] <=> $b['order']); return $items; diff --git a/backend/resources/js/Pages/operator/Queue.jsx b/backend/resources/js/Pages/operator/Queue.jsx index d2caf9784..aa814e1f7 100644 --- a/backend/resources/js/Pages/operator/Queue.jsx +++ b/backend/resources/js/Pages/operator/Queue.jsx @@ -6,6 +6,7 @@ import { DataTable } from '@openmes/ui/table'; import OperatorLayout from '../../layouts/OperatorLayout'; import LineSync from '../../components/LineSync'; import Tooltip from '../../components/Tooltip'; +import QuantityField from '../../components/QuantityField'; import { formatDate, formatNumber, formatTime } from '../../lib/i18n'; // Geist White restyle: light-only v1 — former `dark:` classes removed. @@ -258,9 +259,9 @@ function DoneQtyModal({ open, onClose, woId, woNo, statusId }) {
{__("Produced quantity")} *
- setQty(e.target.value)} + onChange={setQty} className="w-full rounded-om-sm border border-om-line bg-om-bg font-mono text-3xl font-medium text-center py-4 text-om-ink outline-none focus:border-om-accent focus:ring-2 focus:ring-om-accent/20" placeholder="0" min="0" step="0.01" required autoFocus />

{__("Enter the number of units actually produced.")}

diff --git a/backend/resources/js/Pages/operator/WorkOrderDetail.jsx b/backend/resources/js/Pages/operator/WorkOrderDetail.jsx index 01f6d7bff..4eadc3638 100644 --- a/backend/resources/js/Pages/operator/WorkOrderDetail.jsx +++ b/backend/resources/js/Pages/operator/WorkOrderDetail.jsx @@ -7,6 +7,8 @@ import LineSync from '../../components/LineSync'; import LabelPrintMenu from '../../components/LabelPrintMenu'; import CustomFields from '../../components/CustomFields'; import Tooltip from '../../components/Tooltip'; +import QuantityField from '../../components/QuantityField'; +import { Hook } from '../../lib/hooks'; import EngineeringViewerModal from '../../components/EngineeringViewerModal'; import { packageMeta, isInteractive, formatBytes } from '../../components/engineeringDocuments'; import { apiGet, apiCall } from '../../lib/http'; @@ -395,12 +397,11 @@ function QualityCheckForm({ batch, onClose }) {
{__('Production Quantity')}
- setProductionQty(e.target.value)} + onChange={setProductionQty} className={`${inputCls} font-mono`} placeholder={__('Current production qty')} /> @@ -523,13 +524,12 @@ function ReleaseForm({ batch, onClose }) {
{__('Scrap quantity (optional)')}
- form.setData('scrap_qty', e.target.value)} + onChange={(v) => form.setData('scrap_qty', v)} className={`${inputCls} w-32 font-mono`} placeholder="0" /> @@ -815,7 +815,7 @@ function QuantityCorrection({ step }) { {__('Correct good quantity')} { e.preventDefault(); form.post(`/operator/batch-step/${step.id}/quantity-correction`, { preserveScroll: true }); }}>

{__('Enter the corrected total, not an increment. The original and corrected values are retained in the audit history.')}

- + {Object.entries(form.errors).map(([key, value]) =>

{value}

)} @@ -846,11 +846,11 @@ function QuantityLogForm({ step, throughStation = false, inflight, error, onSubm
e.stopPropagation()}> + + e.stopPropagation()}>
@@ -340,11 +364,11 @@ function CompleteModal({ modal, onClose }) {
Quantity *
- setQty(e.target.value)} + onChange={setQty} className="w-full bg-om-bg border border-om-line rounded-om-sm px-3 py-4 font-mono text-[30px] font-medium tracking-[-0.02em] text-center text-om-ink placeholder:text-om-faintest outline-none transition-colors focus:border-om-accent focus:shadow-[0_0_0_3px_rgba(234,90,43,0.12)]" placeholder="0" min="0" diff --git a/backend/resources/js/Pages/settings/System.jsx b/backend/resources/js/Pages/settings/System.jsx index 103812229..84220b016 100644 --- a/backend/resources/js/Pages/settings/System.jsx +++ b/backend/resources/js/Pages/settings/System.jsx @@ -6,6 +6,9 @@ import AppLayout from '../../layouts/AppLayout'; import useConfirm from '../../components/useConfirm'; import { useToast } from '@openmes/ui'; import { __ } from '../../lib/i18n'; +import { Hook } from '../../lib/hooks'; + +const SYSTEM_TABS_HOOK = 'display.settings.system.tabs'; // Common reporting currencies (ISO 4217). Names are proper nouns, not translated. const CURRENCIES = [ @@ -167,11 +170,20 @@ function TelemetryCard({ enabled, onChange, lastSentAt }) { export default function System() { const toast = useToast(); const { settings, availableLocales, timezones = {}, appUrl, modules = [], backups, - demoDatasets = [], loadedDemoDataset = null } = usePage().props; + demoDatasets = [], loadedDemoDataset = null, hooks = {} } = usePage().props; + + // Tabs an installed module adds (display.settings.system.tabs): one per + // contribution, keyed `ext-` so it is linkable as ?tab=ext-. + // The module's component saves through its own route, so these panels sit + // outside the core form and carry no core Save button. + const extTabs = (hooks[SYSTEM_TABS_HOOK] ?? []) + .filter((c) => c.slot) + .map((c) => ({ value: `ext-${c.slot}`, label: c.title ? __(c.title) : c.slot, contribution: c })); + const activeExtTab = (t) => extTabs.find((e) => e.value === t); // The sidebar links each panel directly (/settings/system?tab=security), so // the opening panel comes from the URL rather than always being General. - const TABS = ['general', 'production', 'schedule', 'security', 'modules', 'data']; + const TABS = ['general', 'production', 'schedule', 'security', 'modules', 'data', ...extTabs.map((e) => e.value)]; const requestedTab = new URLSearchParams(usePage().url.split('?')[1] || '').get('tab'); const [tab, setTab] = useState(TABS.includes(requestedTab) ? requestedTab : 'general'); const [sampleConfirm, setSampleConfirm] = useState(false); @@ -425,6 +437,7 @@ export default function System() { { value: 'security', label: __('Security') }, { value: 'modules', label: __('Modules') }, { value: 'data', label: __('Data') }, + ...extTabs.map(({ value, label }) => ({ value, label })), ]} value={tab} onChange={setTab} @@ -434,578 +447,587 @@ export default function System() { {/* One panel, whose identity follows the active tab: only the selected tab's sections are mounted, so the form *is* the current panel. */} - - {/* ═══ Modules — enable only the feature areas you need (#144) ═══ */} - {tab === 'modules' && ( -
-

{__('Modules')}

-

- {__('Enable only the feature areas your team uses. A disabled module is hidden from the menu and its pages return 404. Core areas (Dashboard, Orders, Production, Admin) are always on.')} -

-
- {modules.map((m) => ( -
- toggleModule(m.key, next)} - label={__(m.label)} - /> - {__(m.description)} -
- ))} -
-
- )} - - {/* ═══ General ═══ */} - {tab === 'general' && ( -
-

{__('Language')}

-
-
{__('Select language')}
- ({ value: String(code), label: name }))} - value={data.language == null ? '' : String(data.language)} - onChange={(v) => setData('language', v)} - className="w-full max-w-xs" - /> -

- {__('Want to add a new language? Create a JSON file in')} lang/ {__('directory.')} - {' '}{__('See')} lang/en.json {__('as reference.')} + {!activeExtTab(tab) && ( + + {/* ═══ Modules — enable only the feature areas you need (#144) ═══ */} + {tab === 'modules' && ( +

+

{__('Modules')}

+

+ {__('Enable only the feature areas your team uses. A disabled module is hidden from the menu and its pages return 404. Core areas (Dashboard, Orders, Production, Admin) are always on.')}

+
+ {modules.map((m) => ( +
+ toggleModule(m.key, next)} + label={__(m.label)} + /> + {__(m.description)} +
+ ))} +
+ )} -
-

{__('Timezone')}

-

- {__('Plant timezone. Every timestamp, report boundary and shift edge in the app is expressed in it.')} -

- setData('app_timezone', v)} - /> - {errors.app_timezone &&

{errors.app_timezone}

} -

- {__('Changing the timezone reloads the page so every displayed time switches over at once.')} -

-
+ {/* ═══ General ═══ */} + {tab === 'general' && ( +
+

{__('Language')}

+
+
{__('Select language')}
+ ({ value: String(code), label: name }))} + value={data.language == null ? '' : String(data.language)} + onChange={(v) => setData('language', v)} + className="w-full max-w-xs" + /> +

+ {__('Want to add a new language? Create a JSON file in')} lang/ {__('directory.')} + {' '}{__('See')} lang/en.json {__('as reference.')} +

+
-
-

{__('Currency')}

-

{__('System-wide currency used across cost reports, pay rates and additional costs.')}

- code === data.default_currency) && data.default_currency - ? [{ value: String(data.default_currency), label: data.default_currency }] - : []), - ...CURRENCIES.map(([code, name]) => ({ value: String(code), label: `${code} - ${__(name)}` })), - ]} - value={data.default_currency == null ? '' : String(data.default_currency)} - onChange={(v) => setData('default_currency', v)} - className="w-64 max-w-full" - /> - {errors.default_currency &&

{errors.default_currency}

} +
+

{__('Timezone')}

+

+ {__('Plant timezone. Every timestamp, report boundary and shift edge in the app is expressed in it.')} +

+ setData('app_timezone', v)} + /> + {errors.app_timezone &&

{errors.app_timezone}

} +

+ {__('Changing the timezone reloads the page so every displayed time switches over at once.')} +

+
+ +
+

{__('Currency')}

+

{__('System-wide currency used across cost reports, pay rates and additional costs.')}

+ code === data.default_currency) && data.default_currency + ? [{ value: String(data.default_currency), label: data.default_currency }] + : []), + ...CURRENCIES.map(([code, name]) => ({ value: String(code), label: `${code} - ${__(name)}` })), + ]} + value={data.default_currency == null ? '' : String(data.default_currency)} + onChange={(v) => setData('default_currency', v)} + className="w-64 max-w-full" + /> + {errors.default_currency &&

{errors.default_currency}

} +
-
- )} + )} + + {/* ═══ Production ═══ */} + {tab === 'production' && ( +
+ {/* Production Period */} +
+

{__('Production Planning')}

+
+ {__('Production Period Split')} +

{__('Determines how work orders are grouped for planning.')}

+
+ {[ + { value: 'none', label: __('None'), desc: __('No period grouping') }, + { value: 'weekly', label: __('Weekly'), desc: __('Group by ISO week (1-53)') }, + { value: 'monthly', label: __('Monthly'), desc: __('Group by month (1-12)') }, + ].map((opt) => ( + setData('production_period', v)} + label={opt.label} + desc={opt.desc} + /> + ))} +
+ {errors.production_period &&

{errors.production_period}

} +
+
- {/* ═══ Production ═══ */} - {tab === 'production' && ( -
- {/* Production Period */} -
-

{__('Production Planning')}

-
- {__('Production Period Split')} -

{__('Determines how work orders are grouped for planning.')}

-
+ {/* Workflow Mode */} +
+

{__('Workflow Mode')}

+

{__('Defines how work order completion is tracked.')}

+
{[ - { value: 'none', label: __('None'), desc: __('No period grouping') }, - { value: 'weekly', label: __('Weekly'), desc: __('Group by ISO week (1-53)') }, - { value: 'monthly', label: __('Monthly'), desc: __('Group by month (1-12)') }, + { value: 'status', label: __('Status'), desc: __('Work order status is changed manually. Board statuses are visual labels.') }, + { value: 'board_status', label: __('Board Status'), desc: __('Moving to a Done status automatically closes the work order.') }, ].map((opt) => ( setData('production_period', v)} + current={data.workflow_mode} + onChange={(v) => setData('workflow_mode', v)} label={opt.label} desc={opt.desc} /> ))}
- {errors.production_period &&

{errors.production_period}

} + {errors.workflow_mode &&

{errors.workflow_mode}

}
-
- {/* Workflow Mode */} -
-

{__('Workflow Mode')}

-

{__('Defines how work order completion is tracked.')}

-
- {[ - { value: 'status', label: __('Status'), desc: __('Work order status is changed manually. Board statuses are visual labels.') }, - { value: 'board_status', label: __('Board Status'), desc: __('Moving to a Done status automatically closes the work order.') }, - ].map((opt) => ( - setData('workflow_mode', v)} - label={opt.label} - desc={opt.desc} - /> - ))} + {/* Production Rules */} +
+

{__('Material availability')}

+ setData('block_negative_stock', v)} label={__('Warn and allow production')} desc={__('Missing receipts may result in a negative stock balance. Operators can continue working.')} /> + setData('block_negative_stock', v)} label={__('Block production when stock is insufficient')} desc={__('Record a material receipt before starting a step that needs more stock.')} />
- {errors.workflow_mode &&

{errors.workflow_mode}

} -
- - {/* Production Rules */} -
-

{__('Material availability')}

- setData('block_negative_stock', v)} label={__('Warn and allow production')} desc={__('Missing receipts may result in a negative stock balance. Operators can continue working.')} /> - setData('block_negative_stock', v)} label={__('Block production when stock is insufficient')} desc={__('Record a material receipt before starting a step that needs more stock.')} /> -
-
-

{__('Production Rules')}

-
-
- setData('allow_overproduction', v)} - /> -
-

{__('Allow overproduction')}

-

{__('Allow operators to record more units than the planned quantity.')}

+
+

{__('Production Rules')}

+
+
+ setData('allow_overproduction', v)} + /> +
+

{__('Allow overproduction')}

+

{__('Allow operators to record more units than the planned quantity.')}

+
-
-
- setData('force_sequential_steps', v)} - /> -
-

{__('Force sequential steps')}

-

{__('Require production steps to be completed in defined order.')}

+
+ setData('force_sequential_steps', v)} + /> +
+

{__('Force sequential steps')}

+

{__('Require production steps to be completed in defined order.')}

+
-
-
- setData('workstation_routing_enabled', v)} - /> -
-

{__('Workstation routing')}

-

{__('When enabled, an operator assigned to a workstation can only start or complete steps assigned to that workstation.')}

+
+ setData('workstation_routing_enabled', v)} + /> +
+

{__('Workstation routing')}

+

{__('When enabled, an operator assigned to a workstation can only start or complete steps assigned to that workstation.')}

+
-
-
- setData('backflush_on_pallet_creation', v)} - /> -
-

{__('Backflush on pallet creation')}

-

{__('When enabled, creating a pallet declares the BOM consumption for the produced quantity and deducts it from stock at that milestone, instead of continuously.')}

+
+ setData('backflush_on_pallet_creation', v)} + /> +
+

{__('Backflush on pallet creation')}

+

{__('When enabled, creating a pallet declares the BOM consumption for the produced quantity and deducts it from stock at that milestone, instead of continuously.')}

+
-
- {/* Barcode Scanner */} -
-

{__('Barcode Scanner')}

-

{__('How the workstation receives input from a barcode scanner.')}

-
- {[ - { value: 'hid', label: __('HID / Keyboard wedge'), desc: __('Scanner acts as a keyboard. Codes are captured automatically on the workstation, no input field required.') }, - { value: 'manual', label: __('Manual entry'), desc: __('Operator typed the code into a visible field and confirms with Enter. Use when no scanner is available.') }, - ].map((opt) => ( - setData('scanner_mode', v)} - label={opt.label} - desc={opt.desc} - /> - ))} + {/* Barcode Scanner */} +
+

{__('Barcode Scanner')}

+

{__('How the workstation receives input from a barcode scanner.')}

+
+ {[ + { value: 'hid', label: __('HID / Keyboard wedge'), desc: __('Scanner acts as a keyboard. Codes are captured automatically on the workstation, no input field required.') }, + { value: 'manual', label: __('Manual entry'), desc: __('Operator typed the code into a visible field and confirms with Enter. Use when no scanner is available.') }, + ].map((opt) => ( + setData('scanner_mode', v)} + label={opt.label} + desc={opt.desc} + /> + ))} +
+ {errors.scanner_mode &&

{errors.scanner_mode}

}
- {errors.scanner_mode &&

{errors.scanner_mode}

} -
- {/* Production Tracking Mode */} -
-

{__('Production Tracking Mode')}

-

{__('How operators register production progress on the shop floor.')}

-
- {[ - { value: 'per_operation', label: __('Per Operation'), desc: __('Operator clicks Start/Complete on each step at each workstation. Full traceability.') }, - { value: 'cumulative', label: __('Cumulative'), desc: __('Operator enters total produced quantity at the end. No step tracking.') }, - { value: 'hybrid', label: __('Hybrid'), desc: __('Key steps tracked per-operation, quantity entry also available. Best of both.') }, - ].map((opt) => ( - setData('production_tracking_mode', v)} - label={opt.label} - desc={opt.desc} - /> - ))} + {/* Production Tracking Mode */} +
+

{__('Production Tracking Mode')}

+

{__('How operators register production progress on the shop floor.')}

+
+ {[ + { value: 'per_operation', label: __('Per Operation'), desc: __('Operator clicks Start/Complete on each step at each workstation. Full traceability.') }, + { value: 'cumulative', label: __('Cumulative'), desc: __('Operator enters total produced quantity at the end. No step tracking.') }, + { value: 'hybrid', label: __('Hybrid'), desc: __('Key steps tracked per-operation, quantity entry also available. Best of both.') }, + ].map((opt) => ( + setData('production_tracking_mode', v)} + label={opt.label} + desc={opt.desc} + /> + ))} +
+ {errors.production_tracking_mode &&

{errors.production_tracking_mode}

}
- {errors.production_tracking_mode &&

{errors.production_tracking_mode}

} -
- {/* Production Flow (whole batch vs transfer between stations) */} -
-

{__('Production Flow')}

-

{__('How pieces move between the steps of a batch.')}

-
- {[ - { value: 'whole_batch', label: __('Whole batch'), desc: __('A station opens only after the previous one has finished the whole batch. Finishing a step passes everything not scrapped.') }, - { value: 'transfer', label: __('Transfer'), desc: __('Pieces move on as soon as they are logged as good, so stations work at the same time. A step finishes once nothing is left waiting.') }, - ].map((opt) => ( - setData('production_flow_mode', v)} - label={opt.label} - desc={opt.desc} - /> - ))} + {/* Production Flow (whole batch vs transfer between stations) */} +
+

{__('Production Flow')}

+

{__('How pieces move between the steps of a batch.')}

+
+ {[ + { value: 'whole_batch', label: __('Whole batch'), desc: __('A station opens only after the previous one has finished the whole batch. Finishing a step passes everything not scrapped.') }, + { value: 'transfer', label: __('Transfer'), desc: __('Pieces move on as soon as they are logged as good, so stations work at the same time. A step finishes once nothing is left waiting.') }, + ].map((opt) => ( + setData('production_flow_mode', v)} + label={opt.label} + desc={opt.desc} + /> + ))} +
+ {errors.production_flow_mode &&

{errors.production_flow_mode}

}
- {errors.production_flow_mode &&

{errors.production_flow_mode}

} -
- {/* Production Quantity Corrections */} -
-

{__('Production Quantity Corrections')}

-

{__('Defines whether and when operators can correct previously reported quantities.')}

-

{__('Transfer step totals require Full edit. Timed windows apply only to individual shift entries.')}

-
- {[ - { value: 'none', label: __('No corrections'), desc: __('Operators cannot edit reported quantities. All entries are final.') }, - { value: 'timed', label: __('Timed window'), desc: __('Operators can correct quantities within a configurable time window after submission.') }, - { value: 'full', label: __('Full edit'), desc: __('Operators can edit reported quantities at any time.') }, - ].map((opt) => ( - setData('production_qty_edit_policy', v)} - label={opt.label} - desc={opt.desc} - /> - ))} + {/* Production Quantity Corrections */} +
+

{__('Production Quantity Corrections')}

+

{__('Defines whether and when operators can correct previously reported quantities.')}

+

{__('Transfer step totals require Full edit. Timed windows apply only to individual shift entries.')}

+
+ {[ + { value: 'none', label: __('No corrections'), desc: __('Operators cannot edit reported quantities. All entries are final.') }, + { value: 'timed', label: __('Timed window'), desc: __('Operators can correct quantities within a configurable time window after submission.') }, + { value: 'full', label: __('Full edit'), desc: __('Operators can edit reported quantities at any time.') }, + ].map((opt) => ( + setData('production_qty_edit_policy', v)} + label={opt.label} + desc={opt.desc} + /> + ))} +
+ {errors.production_qty_edit_policy &&

{errors.production_qty_edit_policy}

} + + {data.production_qty_edit_policy === 'timed' && ( +
+ +

{__('How many minutes after submission an operator can still correct the quantity.')}

+
+ setData('production_qty_edit_window_minutes', parseInt(e.target.value, 10) || 1)} + className={`${INPUT_BASE} w-24`} + min={1} + max={60} + /> + {__('minutes')} +
+ {errors.production_qty_edit_window_minutes && ( +

{errors.production_qty_edit_window_minutes}

+ )} +
+ )}
- {errors.production_qty_edit_policy &&

{errors.production_qty_edit_policy}

} - {data.production_qty_edit_policy === 'timed' && ( -
- -

{__('How many minutes after submission an operator can still correct the quantity.')}

-
+ {/* Labor Costing */} +
+

{__('Labor costing')}

+

{__('Defaults used by the Production Cost report when a worker has no compensation of their own.')}

+
+
+ +

{__('Fallback mode for workers with no pay type set.')}

+ setData('default_pay_type', v)} + className="w-full" + /> + {errors.default_pay_type &&

{errors.default_pay_type}

} +
+
+ +

{__('Converts a weekly salary into an hourly cost (salary / hours).')}

+
+ setData('standard_weekly_hours', parseFloat(e.target.value) || 0)} + className={`${INPUT_BASE} w-28`} + min={1} + max={168} + step="0.5" + /> + {__('hours/week')} +
+ {errors.standard_weekly_hours &&

{errors.standard_weekly_hours}

} +
+
+ +

{__('Fallback rate used when a worker has no rate of their own (applied per the worker\'s pay type). Leave blank for none.')}

setData('production_qty_edit_window_minutes', parseInt(e.target.value, 10) || 1)} - className={`${INPUT_BASE} w-24`} - min={1} - max={60} + id="default_pay_rate" + value={data.default_pay_rate ?? ''} + onChange={(e) => setData('default_pay_rate', e.target.value === '' ? null : parseFloat(e.target.value))} + className={`${INPUT_BASE} w-32`} + min={0} + step="0.0001" /> - {__('minutes')} + {errors.default_pay_rate &&

{errors.default_pay_rate}

}
- {errors.production_qty_edit_window_minutes && ( -

{errors.production_qty_edit_window_minutes}

- )}
- )} +
+ )} + + {/* ═══ Schedule ═══ */} + {tab === 'schedule' && ( +
+
+

{__('Schedule / Planner')}

+

{__('Configure how the production schedule planner displays data.')}

+
- {/* Labor Costing */} -
-

{__('Labor costing')}

-

{__('Defaults used by the Production Cost report when a worker has no compensation of their own.')}

-
-
- -

{__('Fallback mode for workers with no pay type set.')}

- setData('default_pay_type', v)} - className="w-full" - /> - {errors.default_pay_type &&

{errors.default_pay_type}

} -
-
- -

{__('Converts a weekly salary into an hourly cost (salary / hours).')}

-
- setData('standard_weekly_hours', parseFloat(e.target.value) || 0)} - className={`${INPUT_BASE} w-28`} - min={1} - max={168} - step="0.5" + {/* View mode */} +
+
{__('View mode')}
+

{__('Default time scale for the schedule view.')}

+
+ {[ + { value: 'weekly', label: __('Weekly'), desc: __('Plan by week') }, + { value: 'daily', label: __('Daily'), desc: __('Plan by day') }, + { value: 'monthly', label: __('Monthly'), desc: __('Plan by month') }, + ].map((opt) => ( + setData('schedule_view_mode', v)} + label={opt.label} + desc={opt.desc} /> - {__('hours/week')} -
- {errors.standard_weekly_hours &&

{errors.standard_weekly_hours}

} + ))}
-
- -

{__('Fallback rate used when a worker has no rate of their own (applied per the worker\'s pay type). Leave blank for none.')}

+ {errors.schedule_view_mode &&

{errors.schedule_view_mode}

} +
+ + {/* Shifts per day */} +
+
{__('Shifts per day')}
+

{__('Number of production shifts in a 24-hour period.')}

+
+ {[1, 2, 3, 4].map((n) => ( +
setData('schedule_shifts_per_day', n)} + className={`flex flex-col items-center gap-1 border rounded-om-sm p-3 cursor-pointer transition-colors + ${data.schedule_shifts_per_day === n + ? 'border-om-accent bg-[rgba(234,90,43,.06)]' + : 'border-om-line hover:border-om-faint'}`} + > + {n} + {__(':hours h', { hours: Math.floor(24 / n) })} +
+ ))} +
+ {errors.schedule_shifts_per_day &&

{errors.schedule_shifts_per_day}

} + + + + + {__('Manage Shifts')} → + +
+ + {/* Planning horizon */} +
+ +

{__('How many weeks ahead the planner displays.')}

+
setData('default_pay_rate', e.target.value === '' ? null : parseFloat(e.target.value))} - className={`${INPUT_BASE} w-32`} - min={0} - step="0.0001" + id="schedule_horizon_weeks" + value={data.schedule_horizon_weeks} + onChange={(e) => setData('schedule_horizon_weeks', parseInt(e.target.value, 10) || 1)} + className={`${INPUT_BASE} w-24`} + min={1} + max={52} /> - {errors.default_pay_rate &&

{errors.default_pay_rate}

} + {__('weeks')}
+ {errors.schedule_horizon_weeks &&

{errors.schedule_horizon_weeks}

}
-
-
- )} - - {/* ═══ Schedule ═══ */} - {tab === 'schedule' && ( -
-
-

{__('Schedule / Planner')}

-

{__('Configure how the production schedule planner displays data.')}

-
- {/* View mode */} -
-
{__('View mode')}
-

{__('Default time scale for the schedule view.')}

-
- {[ - { value: 'weekly', label: __('Weekly'), desc: __('Plan by week') }, - { value: 'daily', label: __('Daily'), desc: __('Plan by day') }, - { value: 'monthly', label: __('Monthly'), desc: __('Plan by month') }, - ].map((opt) => ( - setData('schedule_view_mode', v)} - label={opt.label} - desc={opt.desc} + {/* Show weekends */} +
+
+ setData('schedule_show_weekends', v)} /> - ))} -
- {errors.schedule_view_mode &&

{errors.schedule_view_mode}

} -
- - {/* Shifts per day */} -
-
{__('Shifts per day')}
-

{__('Number of production shifts in a 24-hour period.')}

-
- {[1, 2, 3, 4].map((n) => ( -
setData('schedule_shifts_per_day', n)} - className={`flex flex-col items-center gap-1 border rounded-om-sm p-3 cursor-pointer transition-colors - ${data.schedule_shifts_per_day === n - ? 'border-om-accent bg-[rgba(234,90,43,.06)]' - : 'border-om-line hover:border-om-faint'}`} - > - {n} - {__(':hours h', { hours: Math.floor(24 / n) })} +
+

{__('Show weekends')}

+

{__('Display Saturday and Sunday columns in the schedule view.')}

- ))} +
- {errors.schedule_shifts_per_day &&

{errors.schedule_shifts_per_day}

} - - - - - {__('Manage Shifts')} → - -
- {/* Planning horizon */} -
- -

{__('How many weeks ahead the planner displays.')}

-
- setData('schedule_horizon_weeks', parseInt(e.target.value, 10) || 1)} - className={`${INPUT_BASE} w-24`} - min={1} - max={52} - /> - {__('weeks')} + {/* Realtime updates */} +
+
{__('Realtime updates')}
+

{__('How the planner receives live updates from other users.')}

+
+ setData('realtime_mode', v)} + label={__('Polling')} + desc={__('Checks for changes every few seconds (default)')} + /> + setData('realtime_mode', v)} + label={__('Off')} + desc={__('No automatic refresh — reload the page to see changes')} + /> +
+ {errors.realtime_mode &&

{errors.realtime_mode}

}
- {errors.schedule_horizon_weeks &&

{errors.schedule_horizon_weeks}

}
- - {/* Show weekends */} -
-
- setData('schedule_show_weekends', v)} - /> -
-

{__('Show weekends')}

-

{__('Display Saturday and Sunday columns in the schedule view.')}

+ )} + + {/* ═══ Security ═══ */} + {tab === 'security' && ( +
+ {/* Authentication */} +
+

{__('Authentication')}

+

{__('Additional login methods for operators.')}

+
+
+ setData('pin_login_enabled', v)} + /> +
+

{__('Enable PIN login')}

+

+ {__('Allow users to set a 4–6 digit numeric PIN for quick sign-in. Each user must first configure their PIN in Settings (requires current password). PIN login does not replace password login — it is an alternative method.')} +

+
+
-
- {/* Realtime updates */} -
-
{__('Realtime updates')}
-

{__('How the planner receives live updates from other users.')}

-
- setData('realtime_mode', v)} - label={__('Polling')} - desc={__('Checks for changes every few seconds (default)')} - /> - setData('realtime_mode', v)} - label={__('Off')} - desc={__('No automatic refresh — reload the page to see changes')} - /> -
- {errors.realtime_mode &&

{errors.realtime_mode}

} -
-
- )} + {/* Telemetry */} + setData('telemetry_enabled', v)} + lastSentAt={settings.telemetry_last_sent_at} + /> - {/* ═══ Security ═══ */} - {tab === 'security' && ( -
- {/* Authentication */} -
-

{__('Authentication')}

-

{__('Additional login methods for operators.')}

-
-
- setData('pin_login_enabled', v)} - /> + {/* CORS */} +
+

{__('CORS (Cross-Origin Requests)')}

+

+ {__('Control which external domains can make API requests to this application. Leave empty to block all cross-origin requests (most secure).')} +

+
+
+ +