Truck detention was timed once per delivery, so every truck on a multi-truck last mile was billed the same number of days regardless of when it actually arrived or was released. Each truck now carries its own detention clock (destination arrival → release) with its own rule match, chargeable days and amount; the modal records and displays the window per truck, and the summary shows the longest detention plus the combined total. Also corrected a false no detention rule matches warning that appeared whenever more than one truck was assigned.

This commit is contained in:
Hagernesh
2026-07-27 08:05:12 +00:00
parent 3e2ae80e5a
commit 500668a415
11 changed files with 512 additions and 86 deletions

View File

@@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode;
);
}
type TruckRow = {
vehicleId: string;
label: string;
arrived: Date | null;
returned: Date | null;
};
const plateOf = (a: NonNullable<LastMileRecord['vehicleAssignments']>[number]) =>
[a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId;
/**
* View/override the detention clock (arrival + delivery/return) for a last-mile
* leg, preview the per-truck-per-day charge, and generate the detention invoice.
* Detention is PER TRUCK: every truck reaches the destination and is released at
* its own time, so each row carries its own clock, days and amount. Legs with no
* trucks assigned fall back to the single leg-level window.
*/
export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) {
const { toast } = useToast();
const qc = useQueryClient();
const id = record?.id ?? null;
const assignments = record?.vehicleAssignments ?? [];
const perTruck = assignments.length > 0;
const [rows, setRows] = useState<TruckRow[]>([]);
// Leg-level fallback (no trucks assigned yet).
const [arrived, setArrived] = useState<Date | null>(null);
const [delivered, setDelivered] = useState<Date | null>(null);
useEffect(() => {
setRows(
assignments.map((a) => ({
vehicleId: a.vehicleId,
label: plateOf(a),
// Fall back to the leg-level pair so a truck without its own window
// shows what it is actually being billed on today.
arrived: a.destinationArrivedAt
? new Date(a.destinationArrivedAt)
: record?.arrivedAt
? new Date(record.arrivedAt)
: null,
returned: a.returnedAt
? new Date(a.returnedAt)
: record?.deliveredAt
? new Date(record.deliveredAt)
: null,
})),
);
setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null);
setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null);
}, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]);
const previewQuery = useQuery({
queryKey: ['truck-detention-preview', id],
@@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
enabled: opened && Boolean(id),
});
const preview = previewQuery.data;
// With several trucks the header rule is null by design (each truck resolves
// its own) — only warn when NO truck matched a rule.
const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId);
const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g]));
const saveTimes = useMutation({
mutationFn: () =>
lastMileService.update(id as string, {
arrivedAt: arrived ? arrived.toISOString() : null,
deliveredAt: delivered ? delivered.toISOString() : null,
}),
perTruck
? lastMileService.setDetentionTimes(
id as string,
rows.map((r) => ({
vehicleId: r.vehicleId,
destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null,
returnedAt: r.returned ? r.returned.toISOString() : null,
})),
)
: lastMileService.update(id as string, {
arrivedAt: arrived ? arrived.toISOString() : null,
deliveredAt: delivered ? delivered.toISOString() : null,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void previewQuery.refetch();
toast({ title: 'Detention times saved' });
},
onError: () => toast({ title: 'Save failed', variant: 'destructive' }),
onError: (e: unknown) => {
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast({ title: 'Save failed', description, variant: 'destructive' });
},
});
const generate = useMutation({
@@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
},
});
const handleSave = () => {
const values = perTruck
? rows.flatMap((r) => [r.arrived, r.returned])
: [arrived, delivered];
// No backdating: detention times are recorded as they happen.
if (values.some((v) => isBackdated(v))) {
toast({ variant: 'destructive', title: 'Detention times cannot be in the past' });
return;
}
const reversed = perTruck
? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived)
: arrived && delivered && delivered < arrived
? { label: 'this delivery' }
: undefined;
if (reversed) {
toast({
variant: 'destructive',
title: 'Return time is before arrival',
description: `Check the times for ${reversed.label}.`,
});
return;
}
saveTimes.mutate();
};
const patchRow = (vehicleId: string, patch: Partial<TruckRow>) =>
setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r)));
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
size="xl"
title={
<Text fw={700}>
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
@@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
}
>
<Stack gap="md">
<Group grow align="flex-start">
<DateTimePicker
label="Arrived at"
description="Detention clock start"
value={arrived}
onChange={(v) => setArrived(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
<DateTimePicker
label="Delivered / returned at"
description="Clock end (blank = still out)"
value={delivered}
onChange={(v) => setDelivered(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
</Group>
{perTruck ? (
<Stack gap="xs">
<Text size="sm" c="dimmed">
Each truck has its own detention clock record when it reached the destination and
when it was released. Days and charges are calculated per truck.
</Text>
{rows.map((r) => {
const g = byVehicle.get(r.vehicleId);
return (
<Paper key={r.vehicleId} withBorder p="sm" radius="md">
<Group justify="space-between" mb={6} wrap="nowrap">
<Group gap={8}>
<Text size="sm" fw={600}>
{r.label}
</Text>
{g?.vehicleType && (
<Badge size="xs" variant="light" color="gray">
{g.vehicleType}
</Badge>
)}
</Group>
{g && (
<Group gap={10} wrap="nowrap">
<Text size="xs" c={g.endIsOpen ? 'orange' : 'dimmed'}>
{g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'}
{g.endIsOpen ? ' · still out' : ''}
</Text>
<Text size="sm" fw={700}>
{money(g.amount, preview?.currency ?? 'USD')}
</Text>
</Group>
)}
</Group>
<Group grow align="flex-start">
<DateTimePicker
label="Arrived at destination"
description="Detention clock start"
value={r.arrived}
onChange={(v) => patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })}
minDate={new Date()}
clearable
/>
<DateTimePicker
label="Released / returned at"
description="Clock end (blank = still out)"
value={r.returned}
onChange={(v) => patchRow(r.vehicleId, { returned: v ? new Date(v) : null })}
minDate={new Date()}
clearable
/>
</Group>
{g && !g.ruleId && (
<Text size="xs" c="red" mt={4}>
No detention rule matches this truck type it will not be billed.
</Text>
)}
</Paper>
);
})}
</Stack>
) : (
<>
<Text size="sm" c="dimmed">
No trucks assigned yet this records the delivery-level detention window. Assign
trucks to track each one separately.
</Text>
<Group grow align="flex-start">
<DateTimePicker
label="Arrived at"
description="Detention clock start"
value={arrived}
onChange={(v) => setArrived(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
<DateTimePicker
label="Delivered / returned at"
description="Clock end (blank = still out)"
value={delivered}
onChange={(v) => setDelivered(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
</Group>
</>
)}
<Group justify="flex-end">
<Button
variant="light"
loading={saveTimes.isPending}
onClick={() => {
// No backdating: detention times are recorded as they happen.
if (isBackdated(arrived) || isBackdated(delivered)) {
toast({
variant: 'destructive',
title: 'Detention times cannot be in the past',
});
return;
}
saveTimes.mutate();
}}
>
<Button variant="light" loading={saveTimes.isPending} onClick={handleSave}>
Save times
</Button>
</Group>
@@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
<Alert color="gray" variant="light">
No preview available.
</Alert>
) : !preview.ruleId ? (
) : !hasAnyRule ? (
<Alert color="orange" variant="light">
No active Truck Detention rule matches this booking. Create one under Warehouse Fee rules
(rule type "Truck Detention Cost").
@@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
) : (
<Stack gap="sm">
<Group grow>
<Stat label="Chargeable days" value={preview.chargeableDays} />
<Stat label="Longest detention" value={`${preview.chargeableDays} day(s)`} />
<Stat label="Trucks" value={preview.containerCount} />
<Stat label="Amount" value={money(preview.amount, preview.currency)} strong />
<Stat label="Total amount" value={money(preview.amount, preview.currency)} strong />
</Group>
{preview.endIsOpen && (
<Text size="xs" c="orange">
Still accruing no delivery/return time yet. The amount grows until the vehicle is returned.
Still accruing at least one truck has no release time yet. The amount grows until
every truck is returned.
</Text>
)}
{preview.groups && preview.groups.length > 1 ? (
{preview.groups && preview.groups.length > 0 ? (
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Truck type</Table.Th>
<Table.Th>Trucks</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Days</Table.Th>
<Table.Th ta="right">Rate / truck / day</Table.Th>
<Table.Th ta="right">Rate / day</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{preview.groups.map((g, i) => (
<Table.Tr key={i}>
<Table.Tr key={g.assignmentId ?? i}>
<Table.Td>
{g.vehicleType ?? 'Unknown'}
{g.plateNumber ?? 'Unassigned'}
{!g.ruleId && (
<Text span size="xs" c="red">
{' '}· no rule
</Text>
)}
</Table.Td>
<Table.Td>{g.truckCount}</Table.Td>
<Table.Td>{g.chargeableDays}</Table.Td>
<Table.Td>{g.vehicleType ?? 'Unknown'}</Table.Td>
<Table.Td>
{g.chargeableDays}
{g.endIsOpen && (
<Text span size="xs" c="orange">
{' '}· open
</Text>
)}
</Table.Td>
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
</Table.Tr>