mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 13:38:20 +00:00
Merge branch 'multi-truck-receive' into dev
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user