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 {