mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
Truck detention on lastmile
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user