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 ( {label} {value} ); } type TruckRow = { vehicleId: string; label: string; arrived: Date | null; returned: Date | null; }; const plateOf = (a: NonNullable[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([]); // Leg-level fallback (no trucks assigned yet). const [arrived, setArrived] = useState(null); const [delivered, setDelivered] = useState(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) => setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r))); return ( Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''} } > {perTruck ? ( 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. {rows.map((r) => { const g = byVehicle.get(r.vehicleId); return ( {r.label} {g?.vehicleType && ( {g.vehicleType} )} {g && ( {g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'} {g.endIsOpen ? ' · still out' : ''} {money(g.amount, preview?.currency ?? 'USD')} )} patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })} minDate={new Date()} clearable /> patchRow(r.vehicleId, { returned: v ? new Date(v) : null })} minDate={new Date()} clearable /> {g && !g.ruleId && ( No detention rule matches this truck type — it will not be billed. )} ); })} ) : ( <> No trucks assigned yet — this records the delivery-level detention window. Assign trucks to track each one separately. setArrived(v ? new Date(v) : null)} minDate={new Date()} clearable /> setDelivered(v ? new Date(v) : null)} minDate={new Date()} clearable /> )} {previewQuery.isLoading ? ( ) : !preview ? ( No preview available. ) : !hasAnyRule ? ( No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules (rule type "Truck Detention Cost"). ) : ( {preview.endIsOpen && ( Still accruing — at least one truck has no release time yet. The amount grows until every truck is returned. )} {preview.groups && preview.groups.length > 0 ? ( Truck Type Days Rate / day Amount {preview.groups.map((g, i) => ( {g.plateNumber ?? 'Unassigned'} {!g.ruleId && ( {' '}· no rule )} {g.vehicleType ?? 'Unknown'} {g.chargeableDays} {g.endIsOpen && ( {' '}· open )} {money(g.ratePerDay, preview.currency)} {money(g.amount, preview.currency)} ))}
) : preview.tiers && preview.tiers.length > 0 ? ( From day To day Days Rate / truck / day Amount {preview.tiers.map((t, i) => ( {t.appliedFromDay} {t.appliedToDay} {t.days} {money(t.ratePerDay, preview.currency)} {money(t.amount, preview.currency)} ))}
) : ( Flat {money(preview.ratePerDay, preview.currency)} per truck per day after the grace window. )} {preview.ruleName ?? 'Detention rule'} {preview.billableUnits > 0 && ( {preview.billableUnits} billable truck-day(s) )}
)}
); }