Merge branch 'multi-truck-receive' into dev

This commit is contained in:
hager
2026-09-06 20:13:50 +00:00
4 changed files with 666 additions and 406 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 {