Truck detention on lastmile

This commit is contained in:
Hagernesh
2026-07-07 08:45:51 +00:00
parent 24fa6ab83b
commit 392038852e
15 changed files with 543 additions and 11 deletions

View File

@@ -0,0 +1,215 @@
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 { 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)}
clearable
/>
<DateTimePicker
label="Delivered / returned at"
description="Clock end (blank = still out)"
value={delivered}
onChange={(v) => setDelivered(v ? new Date(v) : null)}
clearable
/>
</Group>
<Group justify="flex-end">
<Button variant="light" loading={saveTimes.isPending} onClick={() => 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.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>
);
}

View File

@@ -58,6 +58,7 @@ import { vehiclesService } from "@/services/vehicles.service";
import { driversService, type Driver } from "@/services/drivers.service";
import { ratesService } from "@/services/rates.service";
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
@@ -544,6 +545,7 @@ const LastMilePage = () => {
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
const [vehicleRows, setVehicleRows] = useState<
Array<{ vehicleId: string | null; containerNumber: string }>
@@ -1360,6 +1362,16 @@ const LastMilePage = () => {
>
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
</Menu.Item>
{/* Truck detention: set/adjust arrival & return times, preview the
per-truck-per-day charge, and generate its invoice. Available
once the vehicle is en route/delivered (clock has a start). */}
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!pastTransit}
onClick={() => setDetentionRecord(row.original)}
>
Truck detention
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -2019,6 +2031,12 @@ const LastMilePage = () => {
item={releaseItem}
truckPrefill={releaseTruckPrefill}
/>
<TruckDetentionModal
opened={Boolean(detentionRecord)}
onClose={() => setDetentionRecord(null)}
record={detentionRecord}
/>
</Stack>
);
};

View File

@@ -373,6 +373,7 @@ function FeeRules() {
cargoTypeCode: '',
containerType: '',
freeDays: 3,
freeHours: 3,
ratePerDay: 0,
tiers: [] as Array<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
currency: 'USD',
@@ -385,6 +386,9 @@ function FeeRules() {
// Double handling is a flat per-unit charge (basis × rate), not day-based:
// no free days, no progressive tiers.
const isDoubleHandling = form.ruleType === 'DOUBLE_HANDLING_FEE';
// Truck detention: per truck per day after an HOURS-based grace (default 3h),
// with day tiers. Uses "free hours" instead of "free days".
const isTruckDetention = form.ruleType === 'TRUCK_DETENTION_FEE';
const resetForm = () =>
setForm({
@@ -396,6 +400,7 @@ function FeeRules() {
cargoTypeCode: '',
containerType: '',
freeDays: 3,
freeHours: 3,
ratePerDay: 0,
tiers: [],
currency: 'USD',
@@ -459,11 +464,12 @@ function FeeRules() {
tradeDirection: clean(form.tradeDirection) ?? null,
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
// Double handling: flat basis × rate — no free days, no tiers.
freeDays: isDoubleHandling ? 0 : form.freeDays,
// Double handling: flat basis × rate. Truck detention: HOURS-based grace.
freeDays: isDoubleHandling || isTruckDetention ? 0 : form.freeDays,
ratePerDay: form.ratePerDay,
currency: form.currency || 'USD',
...(isDoubleHandling ? { basis: form.basis } : {}),
...(isTruckDetention ? { freeHours: form.freeHours } : {}),
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
};
@@ -668,6 +674,14 @@ function FeeRules() {
}
allowDeselect={false}
/>
) : isTruckDetention ? (
<NumberInput
label="Free hours"
description="Grace before detention accrues"
min={0}
value={form.freeHours}
onChange={(value) => setForm((f) => ({ ...f, freeHours: numberValue(value) }))}
/>
) : (
<NumberInput
label="Free days"

View File

@@ -1,5 +1,6 @@
import { api } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type { FeePreview } from '@/types/warehouse';
export const LAST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -70,6 +71,9 @@ export interface LastMileRecord {
}>;
/** Present only when an invoice has actually been generated (not on distance). */
invoice?: { id: string; number: string; status: string } | null;
/** Truck-detention clock: vehicle arrival + delivery/return times. */
arrivedAt?: string | null;
deliveredAt?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -85,7 +89,7 @@ export const lastMileService = {
list: (pageSize = 1000) =>
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
getById: (id: string) => api.get<LastMileRecord>(LM.BY_ID(id)),
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean }) =>
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean; arrivedAt?: string | null; deliveredAt?: string | null }) =>
api.patch<LastMileRecord>(LM.BY_ID(id), data),
accept: (bookingReference: string) =>
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
@@ -102,4 +106,12 @@ export const lastMileService = {
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
generateInvoice: (id: string) =>
api.post<{ id: string; invoiceNumber?: string } | null>(`${LM.BASE}/${id}/invoice`),
/** Generate a truck-detention invoice (per truck per day after the grace window). */
generateTruckDetentionInvoice: (id: string) =>
api.post<{ id: string; invoiceNumber?: string } | null>(
`${LM.BASE}/${id}/generate-truck-detention-invoice`,
),
/** Preview the truck-detention charge for a last-mile leg. */
truckDetentionPreview: (id: string) =>
api.get<FeePreview>(`${LM.BASE}/${id}/truck-detention-preview`),
};

View File

@@ -779,6 +779,8 @@ export interface FeeRule {
yardId?: string | null;
zoneId?: string | null;
freeDays: number;
/** Truck detention only: grace window in hours (default 3). */
freeHours?: number | null;
ratePerDay: number;
tiers?: FeeRuleTier[];
currency: string;