From 6ebb402131777a2e24de8128930abf1b0edd75b8 Mon Sep 17 00:00:00 2001 From: hager Date: Sun, 6 Sep 2026 19:52:46 +0000 Subject: [PATCH] feat(freight-backoffice): enter several trucks on one arrival The receive drawer captured one truck per arrival, so a customer whose containers came on three trucks had to be received three times over. Stage trucks instead. "Add another truck" validates the form exactly as a single-truck receive does, files the truck with its own plate, driver and containers, and clears the form for the next one. The staged trucks are listed above the form with their boxes, each removable, and the arrival is received in one operation. Containers already staged on an earlier truck drop out of the picker, so the same box cannot be sent twice. Receiving with a part-filled form and trucks already staged is treated as the operator still typing rather than a finished arrival: it asks them to finish or clear the truck instead of silently dropping what they entered. An untouched form receives just the staged trucks. With no trucks staged the drawer sends the original truckEntrance and containerNumbers fields, so a single-truck arrival takes exactly the path it did before. Verified: backoffice type-check reports 982 errors both with and without this change (all pre-existing, none in the touched files); 13 warehouse API suites pass (73 tests). Co-Authored-By: Claude Opus 5 --- .../warehouses/ReceiveInventoryModal.tsx | 206 +++++++++++++++--- .../backoffice/src/types/warehouse.ts | 17 ++ 2 files changed, 189 insertions(+), 34 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 1f3a1cf81..63035a34f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -72,6 +72,7 @@ import type { ImportUnloadedItem, ReadyToLoadRow, ReceiveInventoryPayload, + ReceiveTruckPayload, TruckEntrancePayload, Warehouse, WarehouseInventoryItem, @@ -864,6 +865,12 @@ function EligibleTab({ const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState(null); const [selectedContainerNumbers, setSelectedContainerNumbers] = useState([]); + /** + * Trucks already staged for this arrival. A customer's containers often come + * on several trucks at once; each is captured with its own plate, driver and + * boxes, then the whole arrival is received in one operation. + */ + const [stagedTrucks, setStagedTrucks] = useState([]); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); const canReceiveBooking = (row: EligibleBooking) => @@ -968,10 +975,18 @@ function EligibleTab({ const assignedNumbersForSelectedTruck = new Set( (selectedCustomerTruck?.containers ?? []).map((container) => container.containerNumber.toUpperCase()), ); + // A box already staged on an earlier truck is spoken for — offering it again + // would send the same container twice and be rejected by the API. + const stagedContainerNumbers = new Set( + stagedTrucks.flatMap((truck) => + (truck.containerNumbers ?? []).map((number) => number.toUpperCase()), + ), + ); const selectableContainerUnits = pendingContainerUnits.filter( (unit) => - assignedNumbersForSelectedTruck.size === 0 || - assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase()), + !stagedContainerNumbers.has(unit.containerNumber.toUpperCase()) && + (assignedNumbersForSelectedTruck.size === 0 || + assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase())), ); const selectedContainerUnits = pendingContainerUnits.filter((unit) => selectedContainerNumbers.includes(unit.containerNumber), @@ -1063,17 +1078,25 @@ function EligibleTab({ bookingIds: string[], truckEntrance?: TruckEntrancePayload, containerNumbers?: string[], + trucks?: ReceiveTruckPayload[], ) => { const documentBookingId = direction === 'EXPORT' && bookingIds.length === 1 ? bookingIds[0] : null; const grnWindow = documentBookingId ? window.open('', '_blank') : null; const acceptanceWindow = documentBookingId ? window.open('', '_blank') : null; try { + // A multi-truck arrival sends `trucks` and nothing else: the API reads the + // single-truck fields only when `trucks` is absent, so sending both would + // silently drop the staged list. const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds, - ...(containerNumbers?.length ? { containerNumbers } : {}), - ...(truckEntrance ? { truckEntrance } : {}), + ...(trucks?.length + ? { trucks } + : { + ...(containerNumbers?.length ? { containerNumbers } : {}), + ...(truckEntrance ? { truckEntrance } : {}), + }), }); const receivedProgress = r.results.find( (item) => item.receivedContainers != null && item.remainingContainers != null, @@ -1081,7 +1104,11 @@ function EligibleTab({ toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, description: receivedProgress - ? `${containerNumbers?.length ?? 0} container(s) arrived. ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.` + ? `${ + trucks?.length + ? trucks.reduce((sum, t) => sum + (t.containerNumbers?.length ?? 0), 0) + : (containerNumbers?.length ?? 0) + } container(s) arrived on ${trucks?.length ?? 1} truck(s). ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.` : r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results), }); const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber); @@ -1195,6 +1222,7 @@ function EligibleTab({ setPendingReceiveIds(filteredIds); setSelectedCustomerTruckId(null); setSelectedContainerNumbers([]); + setStagedTrucks([]); setReceivedAt(new Date().toISOString()); setTruckForm(normalizedForm); setLockedTruckFields({ @@ -1214,40 +1242,98 @@ function EligibleTab({ setTruckOpen(true); }; - const receive = async () => { + /** + * Validate whatever is currently in the truck form. Shared by "Add truck" and + * the final receive so a staged truck is held to exactly the same rules as a + * single-truck arrival. + */ + const truckFormError = (): string | null => { if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim()) { - toast({ variant: 'destructive', title: 'Truck and driver information are required' }); - return; + return 'Truck and driver information are required'; } if (!pendingUsesFirstMile && truckForm.weighingRequired == null) { - toast({ variant: 'destructive', title: 'Select whether customer truck weighing is required' }); - return; + return 'Select whether customer truck weighing is required'; } if (truckForm.weighingRequired && (truckForm.grossWeightKg === '' || truckForm.exitTareWeightKg === '')) { - toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' }); - return; + return 'Gross weight and exit tare weight are required when weighing is Yes'; } if (pendingContainerBooking && selectedContainerNumbers.length === 0) { - toast({ variant: 'destructive', title: 'Select the containers arriving on this truck' }); + return 'Select the containers arriving on this truck'; + } + return containerCapacityError; + }; + + /** The current form as a payload, with the container summary fields filled in. */ + const currentTruckPayload = (): ReceiveTruckPayload => ({ + truckEntrance: toTruckEntrancePayload({ + ...truckForm, + ...(pendingContainerBooking + ? { + assignedEquipmentNumber: selectedContainerNumbers.join(', '), + unitCount: selectedContainerNumbers.length, + netWeightKg: selectedContainerWeight, + } + : {}), + }), + ...(pendingContainerBooking ? { containerNumbers: selectedContainerNumbers } : {}), + }); + + /** Stage the truck on screen and clear the form for the next one. */ + const addTruck = () => { + const error = truckFormError(); + if (error) { + toast({ variant: 'destructive', title: error }); return; } - if (containerCapacityError) { - toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError }); + setStagedTrucks((current) => [...current, currentTruckPayload()]); + setTruckForm(emptyTruckEntrance()); + setSelectedContainerNumbers([]); + setSelectedCustomerTruckId(null); + setLockedTruckFields({}); + }; + + const removeStagedTruck = (index: number) => + setStagedTrucks((current) => current.filter((_, position) => position !== index)); + + const receive = async () => { + // With trucks staged, a part-filled form is the operator still typing the + // next truck — receiving would silently drop it, so make them finish or + // clear it. An empty form just means every truck is already staged. + const formTouched = + truckForm.truckPlateNumber.trim() !== '' || + truckForm.driverName.trim() !== '' || + selectedContainerNumbers.length > 0; + + if (stagedTrucks.length > 0 && !formTouched) { + await receiveBookings(pendingReceiveIds, undefined, undefined, stagedTrucks); return; } + + const error = truckFormError(); + if (error) { + toast({ + variant: 'destructive', + title: error, + ...(stagedTrucks.length > 0 + ? { description: 'Finish this truck or clear it, then receive the arrival.' } + : {}), + }); + return; + } + + if (stagedTrucks.length > 0) { + await receiveBookings(pendingReceiveIds, undefined, undefined, [ + ...stagedTrucks, + currentTruckPayload(), + ]); + return; + } + + const single = currentTruckPayload(); await receiveBookings( pendingReceiveIds, - toTruckEntrancePayload({ - ...truckForm, - ...(pendingContainerBooking - ? { - assignedEquipmentNumber: selectedContainerNumbers.join(', '), - unitCount: selectedContainerNumbers.length, - netWeightKg: selectedContainerWeight, - } - : {}), - }), - pendingContainerBooking ? selectedContainerNumbers : undefined, + single.truckEntrance, + single.containerNumbers, ); }; @@ -1629,6 +1715,44 @@ function EligibleTab({ + {stagedTrucks.length > 0 ? ( + + + Trucks in this arrival ({stagedTrucks.length}) + + {stagedTrucks.map((truck, index) => ( + + + + {truck.truckEntrance.truckPlateNumber} + {truck.truckEntrance.driverName ? ` — ${truck.truckEntrance.driverName}` : ''} + + + {truck.containerNumbers?.length + ? truck.containerNumbers.join(', ') + : 'Bulk arrival'} + + + + + ))} + + ) : null} - - - + + + + diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 47107ff9a..dae85dad9 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -588,14 +588,31 @@ export interface EligibleBooking { remainingContainerCount: number; } +/** + * One truck in a multi-truck arrival, with the containers it carries. Each + * truck keeps its own plate, driver and load, and the API validates capacity + * and container-to-plate assignment per truck. + */ +export interface ReceiveTruckPayload { + truckEntrance: TruckEntrancePayload; + containerNumbers?: string[]; + /** Defaults to the operation's bookingIds when omitted. */ + bookingIds?: string[]; +} + export interface BulkReceivePayload { direction: 'IMPORT' | 'EXPORT'; warehouseId: string; yardId: string; zoneId: string; bookingIds: string[]; + /** + * Single-truck arrival. Superseded by `trucks` when several trucks deliver + * the same arrival; the API accepts either shape. + */ containerNumbers?: string[]; truckEntrance?: TruckEntrancePayload; + trucks?: ReceiveTruckPayload[]; } export interface BulkReceiveResult {