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 <noreply@anthropic.com>
This commit is contained in:
hager
2026-09-06 19:52:46 +00:00
parent c2fb320709
commit 6ebb402131
2 changed files with 189 additions and 34 deletions

View File

@@ -72,6 +72,7 @@ import type {
ImportUnloadedItem,
ReadyToLoadRow,
ReceiveInventoryPayload,
ReceiveTruckPayload,
TruckEntrancePayload,
Warehouse,
WarehouseInventoryItem,
@@ -864,6 +865,12 @@ function EligibleTab({
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState<string | null>(null);
const [selectedContainerNumbers, setSelectedContainerNumbers] = useState<string[]>([]);
/**
* 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<ReceiveTruckPayload[]>([]);
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({
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{stagedTrucks.length > 0 ? (
<Stack gap={6}>
<Text size="sm" fw={600}>
Trucks in this arrival ({stagedTrucks.length})
</Text>
{stagedTrucks.map((truck, index) => (
<Group
key={`${truck.truckEntrance.truckPlateNumber}-${index}`}
justify="space-between"
wrap="nowrap"
px="sm"
py={6}
style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}
>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{truck.truckEntrance.truckPlateNumber}
{truck.truckEntrance.driverName ? `${truck.truckEntrance.driverName}` : ''}
</Text>
<Text size="xs" c="dimmed">
{truck.containerNumbers?.length
? truck.containerNumbers.join(', ')
: 'Bulk arrival'}
</Text>
</Stack>
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={() => removeStagedTruck(index)}
disabled={bulkReceive.isPending}
>
Remove
</Button>
</Group>
))}
</Stack>
) : null}
<TruckEntranceFields
value={truckForm}
onChange={setTruckForm}
@@ -1636,15 +1760,29 @@ function EligibleTab({
packagingFreightType={packagingFreightType}
allowTruckWeighing={!pendingUsesFirstMile}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
{pendingContainerBooking
? 'Receive Selected Containers & Generate CAS + GRN'
: 'Register Arrival & Generate GRN'}
<Group justify="space-between">
{/* Staging a truck clears the form for the next one; the arrival is
received once every truck has been entered. */}
<Button
variant="light"
leftSection={<Truck size={16} />}
onClick={addTruck}
disabled={bulkReceive.isPending || selectableContainerUnits.length === 0}
>
Add another truck
</Button>
<Group>
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
{stagedTrucks.length > 0
? `Receive ${stagedTrucks.length} Truck(s) & Generate CAS + GRN`
: pendingContainerBooking
? 'Receive Selected Containers & Generate CAS + GRN'
: 'Register Arrival & Generate GRN'}
</Button>
</Group>
</Group>
</Stack>
</Modal>

View File

@@ -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 {