mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
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>
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Checkbox,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
Select,
|
|
Stack,
|
|
Table,
|
|
Tabs,
|
|
Text,
|
|
Tooltip,
|
|
} from '@mantine/core';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { FileText } from 'lucide-react';
|
|
import { useMemo, useState } from 'react';
|
|
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import {
|
|
warehouseService,
|
|
type ContainerItem,
|
|
type ContainerItemStage,
|
|
} from '@/services/warehouse.service';
|
|
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
|
import { openPdfBlob } from './pdf';
|
|
|
|
interface ContainerItemsModalProps {
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
bookingId: string | null;
|
|
bookingReference?: string | null;
|
|
}
|
|
|
|
const STAGE_TABS: Array<{ value: string; label: string }> = [
|
|
{ value: 'ALL', label: 'All' },
|
|
{ value: 'RECEIVED', label: 'Received' },
|
|
{ value: 'GRN', label: "GRN'd" },
|
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
|
{ value: 'LOADED', label: 'Loaded' },
|
|
{ value: 'LEFT', label: 'Left' },
|
|
{ value: 'DELIVERED', label: 'Delivered' },
|
|
];
|
|
|
|
const STAGE_COLOR: Record<ContainerItemStage, string> = {
|
|
PENDING: 'gray',
|
|
RECEIVED: 'blue',
|
|
GRN: 'teal',
|
|
ASSIGNED: 'indigo',
|
|
LOADED: 'grape',
|
|
LEFT: 'orange',
|
|
DELIVERED: 'green',
|
|
};
|
|
|
|
/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */
|
|
const isLoadable = (i: ContainerItem) =>
|
|
i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
|
|
|
|
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [tab, setTab] = useState('ALL');
|
|
const [selected, setSelected] = useState<string[]>([]);
|
|
const [truckId, setTruckId] = useState<string | null>(null);
|
|
|
|
const itemsKey = ['container-items', bookingId];
|
|
const { data: items = [], isLoading } = useQuery({
|
|
queryKey: itemsKey,
|
|
queryFn: () => warehouseService.getContainerItems(bookingId as string),
|
|
enabled: opened && Boolean(bookingId),
|
|
});
|
|
const { data: trucks = [] } = useQuery({
|
|
queryKey: ['ci-trucks', bookingId],
|
|
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
|
enabled: opened && Boolean(bookingId),
|
|
});
|
|
|
|
const visible = useMemo(
|
|
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
|
[items, tab],
|
|
);
|
|
// 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) => !(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),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: itemsKey });
|
|
setSelected([]);
|
|
toast({ title: 'Containers loaded onto truck' });
|
|
},
|
|
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
|
});
|
|
|
|
const requestSign = async () => {
|
|
try {
|
|
const res = await warehouseService.requestHandoverSignature(bookingId as string);
|
|
queryClient.invalidateQueries({ queryKey: itemsKey });
|
|
if (res.alreadySigned) {
|
|
toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' });
|
|
} else {
|
|
toast({
|
|
title: 'Handover not signed',
|
|
description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) });
|
|
}
|
|
};
|
|
|
|
const openExitPaper = async (assignmentId: string, plate: string) => {
|
|
try {
|
|
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
|
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
|
} catch (e) {
|
|
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
|
|
}
|
|
};
|
|
|
|
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
|
|
opened={opened}
|
|
onClose={onClose}
|
|
centered
|
|
size="90%"
|
|
title={<Text fw={700}>Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}</Text>}
|
|
>
|
|
<Tabs value={tab} onChange={(v) => setTab(v ?? 'ALL')} mb="sm">
|
|
<Tabs.List>
|
|
{STAGE_TABS.map((t) => {
|
|
const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length;
|
|
return (
|
|
<Tabs.Tab key={t.value} value={t.value} rightSection={<Badge size="xs" variant="light">{count}</Badge>}>
|
|
{t.label}
|
|
</Tabs.Tab>
|
|
);
|
|
})}
|
|
</Tabs.List>
|
|
</Tabs>
|
|
|
|
{isLoading ? (
|
|
<Group justify="center" py="lg">
|
|
<Loader />
|
|
</Group>
|
|
) : items.length === 0 ? (
|
|
<Alert color="gray" variant="light">No container or bulk items on this booking.</Alert>
|
|
) : (
|
|
<Stack gap="sm">
|
|
<Table.ScrollContainer minWidth={900}>
|
|
<Table striped highlightOnHover verticalSpacing="xs">
|
|
<Table.Thead>
|
|
<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>
|
|
<Table.Th>Booking</Table.Th>
|
|
<Table.Th>Contract</Table.Th>
|
|
<Table.Th>Last mile</Table.Th>
|
|
<Table.Th ta="right">Actions</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{visible.map((i) => (
|
|
<Table.Tr key={i.containerNumber}>
|
|
<Table.Td>
|
|
<Checkbox
|
|
checked={selected.includes(i.containerNumber)}
|
|
onChange={() => toggle(i.containerNumber)}
|
|
disabled={!isLoadable(i)}
|
|
/>
|
|
</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>
|
|
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
|
|
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
|
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
|
<Table.Td ta="right">
|
|
{i.loaded && i.truckAssignmentId && (
|
|
<Tooltip
|
|
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
|
disabled={i.handoverSigned}
|
|
withArrow
|
|
multiline
|
|
w={240}
|
|
>
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
color={i.handoverSigned ? 'orange' : 'gray'}
|
|
leftSection={<FileText size={13} />}
|
|
onClick={() =>
|
|
i.handoverSigned
|
|
? openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')
|
|
: requestSign()
|
|
}
|
|
>
|
|
Exit Paper
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
|
|
{/* Multiselect → load onto a truck */}
|
|
<Group justify="space-between" align="flex-end">
|
|
<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 truck assigned'}
|
|
data={truckOptions}
|
|
value={truckId}
|
|
onChange={setTruckId}
|
|
disabled={truckOptions.length === 0}
|
|
w={260}
|
|
/>
|
|
<Button
|
|
color="edr-green"
|
|
disabled={selected.length === 0 || !truckId}
|
|
loading={loadMutation.isPending}
|
|
onClick={() => loadMutation.mutate()}
|
|
>
|
|
Load selected
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|