Files
edr-platform/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx

263 lines
9.3 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>
);
}
/**
* 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.
*/
export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) {
const { toast } = useToast();
const qc = useQueryClient();
const id = record?.id ?? null;
const [arrived, setArrived] = useState<Date | null>(null);
const [delivered, setDelivered] = useState<Date | null>(null);
useEffect(() => {
setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null);
setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null);
}, [record?.id, record?.arrivedAt, record?.deliveredAt, 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;
const saveTimes = useMutation({
mutationFn: () =>
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' }),
});
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' });
},
});
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
title={
<Text fw={700}>
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
</Text>
}
>
<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>
<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();
}}
>
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>
) : !preview.ruleId ? (
<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="Chargeable days" value={preview.chargeableDays} />
<Stat label="Trucks" value={preview.containerCount} />
<Stat label="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.
</Text>
)}
{preview.groups && preview.groups.length > 1 ? (
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Truck type</Table.Th>
<Table.Th>Trucks</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.groups.map((g, i) => (
<Table.Tr key={i}>
<Table.Td>
{g.vehicleType ?? 'Unknown'}
{!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 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>
);
}