mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
404 lines
15 KiB
TypeScript
404 lines
15 KiB
TypeScript
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Divider,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
Paper,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
} from '@mantine/core';
|
|
import { DateTimePicker } from '@mantine/dates';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Receipt } from 'lucide-react';
|
|
import { useEffect, useState } from 'react';
|
|
|
|
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import { isBackdated } from '@/lib/no-backdate';
|
|
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
|
|
|
|
interface TruckDetentionModalProps {
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
record: LastMileRecord | null;
|
|
}
|
|
|
|
const money = (amount: number, currency: string) =>
|
|
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
|
|
|
function Stat({ label, value, strong }: { label: string; value: React.ReactNode; strong?: boolean }) {
|
|
return (
|
|
<Paper withBorder p="sm" radius="md" style={{ flex: 1, minWidth: 120 }}>
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
|
{label}
|
|
</Text>
|
|
<Text size={strong ? 'lg' : 'md'} fw={strong ? 800 : 600}>
|
|
{value}
|
|
</Text>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
/**
|
|
* 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);
|
|
// 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],
|
|
queryFn: async () => (await lastMileService.truckDetentionPreview(id as string)).data,
|
|
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: () =>
|
|
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: (e: unknown) => {
|
|
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
toast({ title: 'Save failed', description, variant: 'destructive' });
|
|
},
|
|
});
|
|
|
|
const generate = useMutation({
|
|
mutationFn: () => lastMileService.generateTruckDetentionInvoice(id as string),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
|
toast({ title: 'Truck detention invoice generated' });
|
|
onClose();
|
|
},
|
|
onError: (e: unknown) => {
|
|
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
toast({ title: 'Detention invoice failed', description, variant: 'destructive' });
|
|
},
|
|
});
|
|
|
|
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="xl"
|
|
title={
|
|
<Text fw={700}>
|
|
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
|
|
</Text>
|
|
}
|
|
>
|
|
<Stack gap="md">
|
|
{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={handleSave}>
|
|
Save times
|
|
</Button>
|
|
</Group>
|
|
|
|
<Divider label="Detention preview" labelPosition="left" />
|
|
|
|
{previewQuery.isLoading ? (
|
|
<Group justify="center" py="md">
|
|
<Loader />
|
|
</Group>
|
|
) : !preview ? (
|
|
<Alert color="gray" variant="light">
|
|
No preview available.
|
|
</Alert>
|
|
) : !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").
|
|
</Alert>
|
|
) : (
|
|
<Stack gap="sm">
|
|
<Group grow>
|
|
<Stat label="Longest detention" value={`${preview.chargeableDays} day(s)`} />
|
|
<Stat label="Trucks" value={preview.containerCount} />
|
|
<Stat label="Total amount" value={money(preview.amount, preview.currency)} strong />
|
|
</Group>
|
|
{preview.endIsOpen && (
|
|
<Text size="xs" c="orange">
|
|
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 > 0 ? (
|
|
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Truck</Table.Th>
|
|
<Table.Th>Type</Table.Th>
|
|
<Table.Th>Days</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={g.assignmentId ?? i}>
|
|
<Table.Td>
|
|
{g.plateNumber ?? 'Unassigned'}
|
|
{!g.ruleId && (
|
|
<Text span size="xs" c="red">
|
|
{' '}· no rule
|
|
</Text>
|
|
)}
|
|
</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>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
) : preview.tiers && preview.tiers.length > 0 ? (
|
|
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>From day</Table.Th>
|
|
<Table.Th>To day</Table.Th>
|
|
<Table.Th>Days</Table.Th>
|
|
<Table.Th ta="right">Rate / truck / day</Table.Th>
|
|
<Table.Th ta="right">Amount</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{preview.tiers.map((t, i) => (
|
|
<Table.Tr key={i}>
|
|
<Table.Td>{t.appliedFromDay}</Table.Td>
|
|
<Table.Td>{t.appliedToDay}</Table.Td>
|
|
<Table.Td>{t.days}</Table.Td>
|
|
<Table.Td ta="right">{money(t.ratePerDay, preview.currency)}</Table.Td>
|
|
<Table.Td ta="right">{money(t.amount, preview.currency)}</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
) : (
|
|
<Text size="sm" c="dimmed">
|
|
Flat {money(preview.ratePerDay, preview.currency)} per truck per day after the grace window.
|
|
</Text>
|
|
)}
|
|
<Group gap="xs">
|
|
<Badge variant="light" color="gray">
|
|
{preview.ruleName ?? 'Detention rule'}
|
|
</Badge>
|
|
{preview.billableUnits > 0 && (
|
|
<Text size="xs" c="dimmed">
|
|
{preview.billableUnits} billable truck-day(s)
|
|
</Text>
|
|
)}
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
|
|
<Group justify="flex-end" mt="sm">
|
|
<Button variant="default" onClick={onClose}>
|
|
Close
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
leftSection={<Receipt size={16} />}
|
|
disabled={!preview || preview.amount <= 0}
|
|
loading={generate.isPending}
|
|
onClick={() => generate.mutate()}
|
|
>
|
|
Generate invoice
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|