mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
feat(warehouse,last-mile): truck load size rule, dedup last-mile, driver-required + arrival prefill
Truck loading: - loadTruck enforces max 2 containers / one 40ft (two 20ft) and auto-marks an assigned truck arrived on load; container-items payload + modal expose container size with a client-side selection cap. - Show "#x containers pending assignment" in the portal truck card and the backoffice container modal. Last-mile: - create() is idempotent — return the existing record for a booking instead of inserting a duplicate delivery row (fixed the same booking showing twice in Assign-Mile). - setVehicles/update reject a truck with no assigned driver; the Assign toast now surfaces the reason. - New GET /last-mile/booking/:id/arrival-trucks returns assigned EDR trucks with driver details; ReleaseOrderModal fetches and auto-fills them so an assigned EDR truck no longer reads as "not assigned yet". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,14 +80,17 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||
[items, tab],
|
||||
);
|
||||
// Only arrived, not-yet-departed trucks can be loaded.
|
||||
// Any assigned, not-yet-departed truck can be loaded here — loading a truck at
|
||||
// the warehouse auto-marks it arrived on the backend, so assigned-but-not-yet-
|
||||
// arrived trucks are selectable too (labelled "assigned" until they arrive).
|
||||
const truckOptions = trucks
|
||||
.filter(
|
||||
(t) =>
|
||||
Boolean((t as { arrivedAt?: string }).arrivedAt) &&
|
||||
!(t as { departedAt?: string }).departedAt,
|
||||
)
|
||||
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||
.filter((t) => !(t as { departedAt?: string }).departedAt)
|
||||
.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.plateNumber} · ${t.driverName}${
|
||||
(t as { arrivedAt?: string }).arrivedAt ? '' : ' (assigned)'
|
||||
}`,
|
||||
}));
|
||||
|
||||
const loadMutation = useMutation({
|
||||
mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected),
|
||||
@@ -125,7 +128,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n]));
|
||||
const is40 = (n: string) =>
|
||||
(items.find((i) => i.containerNumber === n)?.containerSize ?? '').includes('40');
|
||||
|
||||
// A truck carries at most 2 containers, and a 40ft fills the truck (max 1).
|
||||
const toggle = (n: string) =>
|
||||
setSelected((s) => {
|
||||
if (s.includes(n)) return s.filter((x) => x !== n);
|
||||
const next = [...s, n];
|
||||
if (next.length > 2) {
|
||||
toast({ variant: 'destructive', title: 'A truck carries at most 2 containers' });
|
||||
return s;
|
||||
}
|
||||
if (next.length > 1 && next.some(is40)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'A 40ft container fills the truck',
|
||||
description: 'Load only one 40ft container per truck.',
|
||||
});
|
||||
return s;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -162,6 +186,7 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
<Table.Tr>
|
||||
<Table.Th />
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Goods</Table.Th>
|
||||
<Table.Th>Stage</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
@@ -182,6 +207,15 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fw={600}>{i.containerNumber}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
{i.containerSize ? (
|
||||
<Badge variant="light" color={i.containerSize.includes('40') ? 'grape' : 'blue'}>
|
||||
{i.containerSize}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text c="dimmed" size="sm">bulk</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{i.goods ?? '—'}</Table.Td>
|
||||
<Table.Td><Badge color={STAGE_COLOR[i.stage]} variant="light">{i.stage}</Badge></Table.Td>
|
||||
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
|
||||
@@ -221,11 +255,17 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
|
||||
{/* Multiselect → load onto a truck */}
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Text size="sm" c="dimmed">{selected.length} selected</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{selected.length} selected
|
||||
{(() => {
|
||||
const pending = items.filter((i) => !i.truckAssignmentId).length;
|
||||
return pending > 0 ? ` · ${pending} container${pending === 1 ? '' : 's'} pending assignment` : '';
|
||||
})()}
|
||||
</Text>
|
||||
<Group gap="sm" align="flex-end">
|
||||
<Select
|
||||
label="Load onto truck"
|
||||
placeholder={truckOptions.length ? 'Select truck' : 'No arrived truck'}
|
||||
placeholder={truckOptions.length ? 'Select truck' : 'No truck assigned'}
|
||||
data={truckOptions}
|
||||
value={truckId}
|
||||
onChange={setTruckId}
|
||||
|
||||
@@ -121,6 +121,14 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
// EDR last-mile trucks assigned to this booking — surfaced even when the modal
|
||||
// is opened from the warehouse flow (which passes no truckPrefill prop), so an
|
||||
// assigned EDR truck no longer shows as "not assigned yet".
|
||||
const { data: lastMileTrucks = [] } = useQuery({
|
||||
queryKey: ['release-last-mile-trucks', bookingId],
|
||||
queryFn: () => warehouseService.getLastMileTrucks(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
// Per-container cargo weights — the truck's net (gross − tare) must equal the
|
||||
// total cargo weight of the containers selected as loaded on it.
|
||||
const { data: containerWeights = [] } = useQuery({
|
||||
@@ -176,6 +184,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
||||
|
||||
// Opened from the warehouse flow (no truckPrefill prop): once the last-mile
|
||||
// truck query resolves, auto-fill the first assigned EDR truck — without
|
||||
// overwriting anything the operator typed or the locked exit-step values.
|
||||
useEffect(() => {
|
||||
if (!opened || truckPrefill || isExitStep) return;
|
||||
const first = lastMileTrucks[0];
|
||||
if (!first) return;
|
||||
setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
|
||||
setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
|
||||
setDriverName((p) => p || first.driverName || '');
|
||||
setDriverLicense((p) => p || first.driverLicense || '');
|
||||
setDriverPhone((p) => p || first.driverPhone || '');
|
||||
setTruckType((p) => p || first.truckType || '');
|
||||
setContainerNumbers((prev) =>
|
||||
prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
|
||||
);
|
||||
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
|
||||
|
||||
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
||||
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
||||
const assignedTruckOptions = [
|
||||
@@ -199,6 +225,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
driverPhone: '',
|
||||
truckType: t.truckType,
|
||||
})),
|
||||
...lastMileTrucks
|
||||
.filter((t) => t.truckPlateNumber || t.vehicleId)
|
||||
.map((t) => ({
|
||||
value: (t.truckPlateNumber || t.vehicleId) as string,
|
||||
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
|
||||
trailerPlate: t.trailerPlateNumber ?? '',
|
||||
driverName: t.driverName ?? '',
|
||||
driverPhone: t.driverPhone ?? '',
|
||||
truckType: t.truckType ?? '',
|
||||
})),
|
||||
];
|
||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||
|
||||
@@ -666,8 +666,12 @@ const LastMilePage = () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: ["vehicles"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Assign failed", variant: "destructive" });
|
||||
onError: (e: unknown) => {
|
||||
// Surface the backend reason (e.g. "Truck … has no assigned driver …").
|
||||
const raw = (e as { response?: { data?: { message?: string | string[] } } })?.response?.data
|
||||
?.message;
|
||||
const description = Array.isArray(raw) ? raw.join(", ") : raw;
|
||||
toast({ title: "Assign failed", description, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | '
|
||||
export interface ContainerItem {
|
||||
containerNumber: string;
|
||||
goods: string | null;
|
||||
/** Container size, e.g. "20ft" / "40ft"; null for bulk. */
|
||||
containerSize: string | null;
|
||||
stage: ContainerItemStage;
|
||||
grnNumber: string | null;
|
||||
truckAssignmentId: string | null;
|
||||
@@ -126,6 +128,18 @@ const cleanParams = (params: object) =>
|
||||
Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null),
|
||||
);
|
||||
|
||||
/** An assigned EDR last-mile truck, shaped for the arrival/exit weighing prefill. */
|
||||
export interface LastMileArrivalTruck {
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
driverName: string | null;
|
||||
driverLicense: string | null;
|
||||
driverPhone: string | null;
|
||||
truckType: string | null;
|
||||
containerNumber: string | null;
|
||||
}
|
||||
|
||||
export const warehouseService = {
|
||||
/** Customer self-haul trucks assigned to a booking (portal multi-truck). */
|
||||
getCustomerTrucks: async (bookingId: string): Promise<Freight.ICustomerTruck[]> => {
|
||||
@@ -133,6 +147,12 @@ export const warehouseService = {
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill). */
|
||||
getLastMileTrucks: async (bookingId: string): Promise<LastMileArrivalTruck[]> => {
|
||||
const { data } = await apiClient.get(`/last-mile/booking/${bookingId}/arrival-trucks`);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Per-container/bulk items of a booking with lifecycle stage + refs. */
|
||||
getContainerItems: async (bookingId: string): Promise<ContainerItem[]> => {
|
||||
const { data } = await apiClient.get(
|
||||
|
||||
Reference in New Issue
Block a user