Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
natib21 835facc65a fix
2026-06-29 11:12:51 +00:00

1251 lines
47 KiB
TypeScript

import { type ReactNode, useMemo, useState } from "react";
import {
ArrowRight,
Eye,
MoreHorizontal,
Printer,
RefreshCw,
Ruler,
Truck,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Menu,
Modal,
NumberInput,
ScrollArea,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import type { ArrivalQueueItem } from "@/types/warehouse";
import { warehouseService } from "@/services/warehouse.service";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import {
LAST_MILE_STATUSES,
type LastMileApiStatus,
type LastMileRecord,
lastMileService,
} from "@/services/last-mile.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
const STATUS_META: Record<LastMileApiStatus, { label: string; color: string }> = {
PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" },
READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" },
IN_TRANSIT: { label: "In Transit", color: "indigo" },
DELIVERED: { label: "Delivered", color: "green" },
};
const NEXT_STATUS: Partial<Record<LastMileApiStatus, LastMileApiStatus>> = {
PAYMENT_PENDING: "READY_TO_TRANSIT",
READY_TO_TRANSIT: "IN_TRANSIT",
IN_TRANSIT: "DELIVERED",
};
type AssignmentStatus = "ASSIGNED" | "UNASSIGNED";
type StatusFilter = "ALL" | LastMileApiStatus | AssignmentStatus;
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "ALL", label: "All" },
...LAST_MILE_STATUSES.filter((s) => s !== "PAYMENT_PENDING").map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
{ value: "ASSIGNED", label: "Assigned" },
{ value: "UNASSIGNED", label: "Unassigned" },
];
const vehicleLabel = (record: LastMileRecord) => {
if (!record.vehicle) return null;
const v = record.vehicle;
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
if (v.code) parts.unshift(v.code);
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
if (plates) parts.push(plates);
return parts.join(" · ");
};
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
const cargoDesc = (r: LastMileRecord) => {
const parts = [r.booking?.cargoType?.cargoTypeName ?? r.booking?.cargoType?.label ?? r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
return parts.join(" · ") || "—";
};
const originYardName = (r: LastMileRecord) =>
r.booking?.originYard?.label ?? r.booking?.originYard?.name ?? "—";
const contactPersonName = (r: LastMileRecord) =>
r.booking?.company?.contactPersonName ?? "—";
const contactPhone = (r: LastMileRecord) =>
r.booking?.company?.contactPersonPhone ?? r.booking?.company?.phone ?? "—";
const requestedDate = (r: LastMileRecord) => {
const d = r.booking?.scheduledDate;
return d ? new Date(d).toISOString().slice(0, 10) : "—";
};
const serviceTypeName = (r: LastMileRecord) =>
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
const InfoRow = ({ label, value }: { label: string; value: string }) => (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text size="sm">{value}</Text>
</Stack>
);
const BookingInfo = ({ record }: { record: LastMileRecord }) => (
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>{bookingRef(record)}</Text>
<Group gap="xs">
<Badge color={STATUS_META[record.status].color} variant="light" size="sm">
{STATUS_META[record.status].label}
</Badge>
<Badge color={isAssigned(record) ? "green" : "orange"} variant="light" size="sm">
{isAssigned(record) ? "Assigned" : "Unassigned"}
</Badge>
</Group>
</Group>
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Customer" value={customerName(record)} />
<InfoRow label="Service type" value={serviceTypeName(record)} />
<InfoRow label="Pickup (origin yard)" value={originYardName(record)} />
<InfoRow label="Destination" value={deliveryLocation(record)} />
<InfoRow label="Cargo" value={cargoDesc(record)} />
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
<InfoRow label="Contact" value={contactPersonName(record)} />
<InfoRow label="Phone" value={contactPhone(record)} />
<InfoRow label="Requested date" value={requestedDate(record)} />
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
</SimpleGrid>
</Stack>
</Card>
);
const tripSlipRows = (record: LastMileRecord): [string, string][] => [
["Customer", customerName(record)],
["Service", serviceTypeName(record)],
["Pickup (origin yard)", originYardName(record)],
["Destination", deliveryLocation(record)],
["Cargo", cargoDesc(record)],
["Advanced Payment", formatPrice(record.advancedPayment)],
["Post Payment", formatPrice(record.remainingPayment)],
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
["Requested date", requestedDate(record)],
["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"],
["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"],
["Status", STATUS_META[record.status].label],
];
const SampleStamp = () => (
<Box style={{ height: 96, display: "flex", alignItems: "center" }}>
<Box
style={{
width: 96,
height: 96,
borderRadius: "50%",
border: "2px solid var(--mantine-color-teal-7)",
transform: "rotate(-12deg)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box
style={{
width: 82,
height: 82,
borderRadius: "50%",
border: "1px solid var(--mantine-color-teal-7)",
color: "var(--mantine-color-teal-7)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
lineHeight: 1.1,
}}
>
<Text size="9px" fw={700} style={{ letterSpacing: 1 }}>EDR FREIGHT</Text>
<Text size="sm" fw={800}>APPROVED</Text>
<Text size="8px" fw={600}>OPERATIONS</Text>
</Box>
</Box>
</Box>
);
const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => (
<Stack gap="sm" style={{ flex: 1, position: "relative", minHeight: stamp ? 130 : undefined }}>
<Text fw={600} size="sm">{title}</Text>
<Group gap="xs" align="flex-end">
<Text size="sm" c="dimmed">Name:</Text>
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
</Group>
<Group gap="xs" align="flex-end">
<Text size="sm" c="dimmed">Signature:</Text>
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
</Group>
{stamp && (
<Box style={{ position: "absolute", right: 4, top: 22, opacity: 0.85, pointerEvents: "none" }}>
{stamp}
</Box>
)}
</Stack>
);
const TripSlipDocument = ({ record }: { record: LastMileRecord }) => (
<Stack gap="md">
<Stack gap={2} align="center">
<Text fw={700}>EDR Freight</Text>
<Text size="sm" c="dimmed" tt="uppercase" fw={600}>Last Mile Trip Slip</Text>
</Stack>
<Group justify="space-between">
<Text size="sm" fw={600}>{bookingRef(record)}</Text>
<Text size="sm" c="dimmed">{requestedDate(record)}</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="xs">
{tripSlipRows(record).map(([label, value]) => (
<InfoRow key={label} label={label} value={value} />
))}
</SimpleGrid>
<Divider label="Acknowledgement" labelPosition="center" />
<Group align="flex-start" gap="xl" wrap="nowrap">
<SignatureBlock title="Driver" />
<SignatureBlock title="Operator" stamp={<SampleStamp />} />
</Group>
</Stack>
);
const escapeHtml = (v: string) =>
v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const buildTripSlipHtml = (record: LastMileRecord) => {
const rows = tripSlipRows(record)
.map(([l, v]) => `<tr><td class="lbl">${escapeHtml(l)}</td><td>${escapeHtml(v)}</td></tr>`)
.join("");
const sig = (title: string, withStamp: boolean) => `
<div class="sign-col">
<div class="sign-title">${title}</div>
<div class="sign-field"><span>Name:</span><span class="line"></span></div>
<div class="sign-field"><span>Signature:</span><span class="line"></span></div>
${withStamp ? '<div class="stamp"><div class="ring"><div class="ring-inner"><span>EDR FREIGHT</span><strong>APPROVED</strong><span>OPERATIONS</span></div></div></div>' : ""}
</div>`;
return `<!doctype html><html><head><meta charset="utf-8" />
<title>Trip Slip ${escapeHtml(bookingRef(record))}</title>
<style>
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; color: #111; margin: 32px; }
.head { text-align: center; margin-bottom: 16px; }
.head h1 { font-size: 18px; margin: 0; }
.head p { font-size: 12px; letter-spacing: 1px; text-transform: uppercase; color: #555; margin: 2px 0 0; }
.meta { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 8px 0 20px; }
td { padding: 5px 6px; border-bottom: 1px solid #eee; vertical-align: top; }
td.lbl { color: #666; text-transform: uppercase; font-size: 11px; font-weight: 700; width: 40%; }
.ack { text-align: center; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #777; margin: 16px 0 8px; }
.signs { display: flex; gap: 32px; }
.sign-col { flex: 1; position: relative; min-height: 130px; }
.sign-title { font-weight: 700; font-size: 13px; margin-bottom: 12px; }
.sign-field { display: flex; gap: 6px; align-items: flex-end; font-size: 12px; color: #666; margin-bottom: 10px; }
.sign-field .line { flex: 1; border-bottom: 1px solid #888; height: 16px; }
.stamp { position: absolute; right: 4px; top: 22px; opacity: 0.85; }
.ring { width: 96px; height: 96px; border-radius: 50%; border: 2px solid #0c7a57; transform: rotate(-12deg); display: flex; align-items: center; justify-content: center; }
.ring-inner { width: 82px; height: 82px; border-radius: 50%; border: 1px solid #0c7a57; color: #0c7a57; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; line-height: 1.1; }
.ring-inner span { font-size: 9px; font-weight: 700; letter-spacing: 1px; }
.ring-inner strong { font-size: 13px; font-weight: 800; }
</style></head>
<body onload="window.print()">
<div class="head"><h1>EDR Freight</h1><p>Last Mile Trip Slip</p></div>
<div class="meta"><span>${escapeHtml(bookingRef(record))}</span><span>${escapeHtml(requestedDate(record))}</span></div>
<table>${rows}</table>
<div class="ack">Acknowledgement</div>
<div class="signs">${sig("Driver", false)}${sig("Operator", true)}</div>
</body></html>`;
};
const LastMilePage = () => {
const { toast } = useToast();
const qc = useQueryClient();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
const [bulkMode, setBulkMode] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [tripSlipOpen, setTripSlipOpen] = useState(false);
const [tripSlipRecord, setTripSlipRecord] = useState<LastMileRecord | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
const [acceptOpen, setAcceptOpen] = useState(false);
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
const [selectedArrivalItems, setSelectedArrivalItems] = useState<ArrivalQueueItem[]>([]);
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
const [arrivalSearch, setArrivalSearch] = useState("");
const [distanceOpen, setDistanceOpen] = useState(false);
const [distanceValue, setDistanceValue] = useState("");
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE.list(),
queryFn: async () => {
const res = await lastMileService.list();
return res.data;
},
});
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "list"],
queryFn: async () => {
const res = await vehiclesService.getAll({ status: "ACTIVE" });
return res.data;
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "LAST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("LAST_MILE");
return res.data;
},
});
const records = listData?.data ?? [];
const vehicleOptions = useMemo(
() =>
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
if (v.code) parts.unshift(v.code);
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
if (plates) parts.push(plates);
return { value: v.id, label: parts.join(" · ") };
}),
[vehiclesData],
);
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: { status?: LastMileApiStatus; vehicleId?: string | null } }) =>
lastMileService.update(id, data),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
},
});
const updateDistanceMutation = useMutation({
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() });
if (activeRecord) {
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)}${distanceValue} km` });
}
closeDistance();
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
},
});
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
queryKey: ["warehouse-inventory", "arrival-queue"],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
enabled: acceptOpen,
});
const arrivalQueue = arrivalQueueData ?? [];
const filteredArrivalQueue = useMemo(() => {
const term = arrivalSearch.trim().toLowerCase();
if (!term) return arrivalQueue;
return arrivalQueue.filter((item) =>
[item.bookingReference, item.customer, item.cargo, item.warehouse, item.yard]
.join(" ")
.toLowerCase()
.includes(term),
);
}, [arrivalQueue, arrivalSearch]);
const acceptMutation = useMutation({
mutationFn: async ({ items, vehicleId }: { items: ArrivalQueueItem[]; vehicleId: string | null }) => {
const created = await Promise.all(
items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)),
);
if (vehicleId) {
await Promise.all(created.map((record) => lastMileService.update(record.id, { vehicleId })));
}
return created;
},
onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
toast({
title: "Last-mile leg created",
description: `${created.length} ${created.length === 1 ? "delivery" : "deliveries"} accepted successfully.`,
});
closeAccept();
},
onError: () => {
toast({ title: "Accept failed", variant: "destructive" });
},
});
const openAccept = () => {
setAcceptOpen(true);
setAcceptStep(1);
setSelectedArrivalItems([]);
setAcceptVehicleValue(null);
setArrivalSearch("");
};
const closeAccept = () => {
setAcceptOpen(false);
setAcceptStep(1);
setSelectedArrivalItems([]);
setAcceptVehicleValue(null);
setArrivalSearch("");
};
const toggleArrivalItem = (item: ArrivalQueueItem) => {
setSelectedArrivalItems((prev) =>
prev.some((i) => i.bookingId === item.bookingId)
? prev.filter((i) => i.bookingId !== item.bookingId)
: [...prev, item],
);
};
const handleAcceptConfirm = () => {
if (!selectedArrivalItems.length) return;
acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue });
};
const openDistance = (id: string) => {
setActiveId(id);
setDistanceValue("");
setDistanceOpen(true);
};
const closeDistance = () => {
setDistanceOpen(false);
setActiveId(null);
setDistanceValue("");
};
const openInvoice = (record: LastMileRecord) => {
setInvoiceRecord(record);
setInvoiceOpen(true);
};
const closeInvoice = () => {
setInvoiceOpen(false);
setInvoiceRecord(null);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
return;
}
let remainingPayment: number | undefined;
if (ratesData?.data) {
const lastMileRate = ratesData.data.find(
(r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (lastMileRate) {
const rateValue = parseFloat(lastMileRate.rateValue);
remainingPayment = distance * rateValue;
}
}
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
};
const activeRecord = useMemo(
() => records.find((r) => r.id === activeId) ?? null,
[records, activeId],
);
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
);
const matchesFilter = (r: LastMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
case "UNASSIGNED": return !isAssigned(r);
default: return r.status === statusFilter;
}
};
const statusCounts = useMemo(() => {
const counts: Record<StatusFilter, number> = {
ALL: records.length,
PAYMENT_PENDING: 0,
READY_TO_TRANSIT: 0,
IN_TRANSIT: 0,
DELIVERED: 0,
ASSIGNED: 0,
UNASSIGNED: 0,
};
for (const r of records) {
counts[r.status] = (counts[r.status] ?? 0) + 1;
if (isAssigned(r)) counts.ASSIGNED += 1;
else counts.UNASSIGNED += 1;
}
return counts;
}, [records]);
const filteredRecords = useMemo(() => {
const term = search.trim().toLowerCase();
return records.filter((r) => {
if (!matchesFilter(r)) return false;
if (!term) return true;
return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)]
.join(" ")
.toLowerCase()
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [records, search, statusFilter, filterPostPaymentPending]);
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
const pagedRecords = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRecords.slice(start, start + pagination.pageSize);
}, [filteredRecords, pagination]);
const openAssign = (id: string | null) => {
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
setBulkMode(false);
setActiveId(resolved);
setVehicleValue(null);
setAssignOpen(true);
};
const openBulkAssign = () => {
setBulkMode(true);
setActiveId(null);
setVehicleValue(null);
setAssignOpen(true);
};
const closeAssign = () => {
setAssignOpen(false);
setBulkMode(false);
setActiveId(null);
setVehicleValue(null);
};
const handleAssign = () => {
if (!vehicleValue) {
toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" });
return;
}
const targetIds = bulkMode
? selectedIds
: [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id));
if (!targetIds.length) return;
const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue;
Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } })))
.then(() => {
toast({
title: "Vehicle assigned",
description: bulkMode ? `${targetIds.length} deliveries → ${selectedLabel}` : selectedLabel,
});
if (bulkMode) setRowSelection({});
closeAssign();
})
.catch(() => void 0);
};
const handleAdvanceStatus = (record: LastMileRecord) => {
const next = NEXT_STATUS[record.status];
if (!next) return;
updateMutation.mutate(
{ id: record.id, data: { status: next } },
{
onSuccess: () =>
toast({ title: "Status updated", description: `${bookingRef(record)}${STATUS_META[next].label}` }),
},
);
};
const handlePrintTripSlip = (record: LastMileRecord) => {
setTripSlipRecord(record);
setTripSlipOpen(true);
};
const printTripSlip = () => {
if (!tripSlipRecord) return;
const win = window.open("", "_blank", "width=820,height=920");
if (!win) {
toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" });
return;
}
win.document.write(buildTripSlipHtml(tripSlipRecord));
win.document.close();
};
const columns = useMemo((): ColumnDef<LastMileRecord>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "select",
size: 40,
meta: { headerClassName, cellClassName },
header: ({ table }) => (
<Checkbox
aria-label="Select all"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
onChange={(e) => table.toggleAllPageRowsSelected(e.currentTarget.checked)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: "bookingRef",
header: "Booking",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <Text size="sm" fw={600}>{bookingRef(row.original)}</Text>,
},
{
id: "customer",
header: "Customer",
meta: { headerClassName, cellClassName },
cell: ({ row }) => customerName(row.original),
},
{
id: "pickup",
header: "Pickup",
meta: { headerClassName, cellClassName },
cell: ({ row }) => originYardName(row.original),
},
{
id: "destination",
header: "Destination",
meta: { headerClassName, cellClassName },
cell: ({ row }) => deliveryLocation(row.original),
},
{
id: "cargo",
header: "Cargo",
meta: { headerClassName, cellClassName },
cell: ({ row }) => cargoDesc(row.original),
},
{
id: "advancedPayment",
header: "Advanced Payment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatPrice(row.original.advancedPayment),
},
{
id: "postPayment",
header: "Post Payment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatPrice(row.original.remainingPayment),
},
{
id: "vehicle",
header: "Vehicle",
meta: { headerClassName, cellClassName },
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed"></Text>,
},
{
id: "estimatedKm",
header: "Est. Distance (KM)",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : <Text c="dimmed"></Text>,
},
{
id: "exactKm",
header: "Actual Distance (KM)",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed"></Text>,
},
{
id: "invoice",
header: "Invoice",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
if (!hasDistance) {
return <Text c="dimmed"></Text>;
}
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
);
},
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return <Badge color={meta.color} variant="light" size="sm">{meta.label}</Badge>;
},
},
{
id: "assignment",
header: "Assignment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={isAssigned(row.original) ? "green" : "orange"} variant="light" size="sm">
{isAssigned(row.original) ? "Assigned" : "Unassigned"}
</Badge>
),
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => {
const assigned = isAssigned(row.original);
const nextStatus = NEXT_STATUS[row.original.status];
const canPrint = row.original.status !== "PAYMENT_PENDING";
return (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Menu position="bottom-end" width={200} withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Delivery actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
disabled={!nextStatus}
onClick={() => handleAdvanceStatus(row.original)}
>
{nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Truck size={15} />}
disabled={assigned}
onClick={() => openAssign(row.original.id)}
>
Assign
</Menu.Item>
<Menu.Item
leftSection={<RefreshCw size={15} />}
disabled={!assigned}
onClick={() => openAssign(row.original.id)}
>
Reassign
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Eye size={15} />}
onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }}
>
View detail
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
onClick={() => handlePrintTripSlip(row.original)}
>
Print trip slip
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vehicleOptions]);
return (
<Stack gap="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<TextInput
placeholder="Search deliveries…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={260}
/>
<Group gap="sm">
{selectedIds.length > 0 && (
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign} styles={{ label: { fontWeight: 500 } }}>
Assign vehicle ({selectedIds.length})
</Button>
)}
<Button leftSection={<Truck size={16} />} onClick={openAccept} styles={{ label: { fontWeight: 500 } }}>
Assign Mile
</Button>
</Group>
</Group>
<Group gap="xs" wrap="wrap">
{FILTER_OPTIONS.map((option) => {
const active = statusFilter === option.value;
return (
<Button
key={option.value}
size="xs"
variant={active ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setStatusFilter(option.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
{option.label} ({statusCounts[option.value] ?? 0})
</Button>
);
})}
<Button
size="xs"
variant={filterPostPaymentPending ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setFilterPostPaymentPending(!filterPostPaymentPending);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
Post Payment Pending
</Button>
</Group>
</Stack>
</Box>
<DataTable
columns={columns}
data={pagedRecords}
status={isLoading ? "loading" : "success"}
emptyMessage="No last-mile deliveries found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRecords.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
enableRowSelection: true,
getRowId: (row) => row.id,
state: { pagination, rowSelection },
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: fp }) => (
<DataTableFooter table={table} pagination={fp} options={{ labels: { items: "deliveries" } }} />
)}
/>
</Stack>
</Card>
{/* 2-step Assign Mile (arrival queue → vehicle) */}
<Modal
opened={acceptOpen}
onClose={closeAccept}
title={
<Text fw={600}>
{acceptStep === 1 ? "Select Arrivals" : "Assign Mile"}
</Text>
}
size="xl"
radius="lg"
centered
>
{acceptStep === 1 ? (
<Stack gap="md">
<TextInput
placeholder="Search by reference, customer, cargo or warehouse…"
value={arrivalSearch}
onChange={(e) => setArrivalSearch(e.currentTarget.value)}
/>
<ScrollArea h={400}>
<Stack gap="xs">
{arrivalLoading ? (
<Text c="dimmed" size="sm" ta="center" py="md">Loading arrivals</Text>
) : filteredArrivalQueue.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" py="md">No arrivals in queue.</Text>
) : (
filteredArrivalQueue.map((item) => {
const checked = selectedArrivalItems.some((i) => i.bookingId === item.bookingId);
return (
<Card
key={item.bookingId}
withBorder
padding="sm"
radius="md"
style={{
cursor: "pointer",
borderColor: checked ? "var(--mantine-color-blue-4)" : "var(--mantine-color-gray-3)",
backgroundColor: checked ? "var(--mantine-color-blue-0)" : "var(--mantine-color-white)",
transition: "background-color 120ms ease, border-color 120ms ease",
}}
onClick={() => toggleArrivalItem(item)}
>
<Group wrap="nowrap" gap="sm">
<Checkbox
checked={checked}
onChange={() => toggleArrivalItem(item)}
onClick={(e) => e.stopPropagation()}
/>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap">
<Text fw={700} size="sm">{item.bookingReference}</Text>
<Text size="xs" c="dimmed">{item.arrivalDate ? item.arrivalDate.slice(0, 10) : "—"}</Text>
</Group>
<Text size="xs" c="dimmed" truncate>{item.customer ?? "—"}</Text>
<Group gap="xs" wrap="wrap">
{item.cargo && <Text size="xs" c="dimmed">{item.cargo}</Text>}
{item.warehouse && <Text size="xs" c="dimmed">· {item.warehouse}</Text>}
{item.yard && <Text size="xs" c="dimmed">· {item.yard}</Text>}
</Group>
</Stack>
</Group>
</Card>
);
})
)}
</Stack>
</ScrollArea>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{selectedArrivalItems.length > 0
? `${selectedArrivalItems.length} selected`
: "Select one or more arrivals"}
</Text>
<Group gap="sm">
<Button variant="default" onClick={closeAccept}>Cancel</Button>
<Button
disabled={selectedArrivalItems.length === 0}
onClick={() => setAcceptStep(2)}
>
Next
</Button>
</Group>
</Group>
</Stack>
) : (
<Stack gap="md">
<Card withBorder padding="sm" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="xs">
<Text size="sm" fw={600} c="dimmed">Selected arrivals ({selectedArrivalItems.length})</Text>
{selectedArrivalItems.map((item) => (
<Group key={item.bookingId} justify="space-between" wrap="nowrap">
<Text size="sm" fw={600}>{item.bookingReference}</Text>
<Text size="xs" c="dimmed">{item.customer ?? "—"}</Text>
<Text size="xs" c="dimmed">{item.warehouse ?? item.yard ?? "—"}</Text>
</Group>
))}
</Stack>
</Card>
<Divider />
<Select
label="Assign Vehicle (optional)"
placeholder="Select a vehicle"
data={vehicleOptions}
value={acceptVehicleValue}
onChange={setAcceptVehicleValue}
searchable
clearable
/>
<Group justify="space-between" gap="sm">
<Button variant="subtle" onClick={() => setAcceptStep(1)}> Back</Button>
<Group gap="sm">
<Button variant="default" onClick={closeAccept}>Cancel</Button>
<Button
onClick={handleAcceptConfirm}
loading={acceptMutation.isPending}
disabled={selectedArrivalItems.length === 0}
>
Accept {selectedArrivalItems.length > 1 ? `${selectedArrivalItems.length} Deliveries` : "Delivery"}
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
{/* Assign / Reassign modal */}
<Modal
opened={assignOpen}
onClose={closeAssign}
title={<Text fw={600}>Assign Vehicle</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{bulkMode ? (
<Text size="sm">
Assigning a vehicle to{" "}
<Text span fw={600}>{selectedIds.length}</Text>{" "}
selected {selectedIds.length === 1 ? "delivery" : "deliveries"}.
</Text>
) : activeRecord ? (
<BookingInfo record={activeRecord} />
) : (
<Text size="sm" c="dimmed">No unassigned deliveries available.</Text>
)}
<Divider />
<Select
label="Vehicle"
placeholder="Select a vehicle"
data={vehicleOptions}
value={vehicleValue}
onChange={setVehicleValue}
searchable
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>Cancel</Button>
<Button
onClick={handleAssign}
loading={updateMutation.isPending}
disabled={bulkMode ? selectedIds.length === 0 : !activeRecord}
>
{!bulkMode && activeRecord && isAssigned(activeRecord) ? "Reassign" : "Assign"}
</Button>
</Group>
</Stack>
</Modal>
{/* View detail modal */}
<Modal
opened={detailOpen}
onClose={() => { setDetailOpen(false); setActiveId(null); }}
title={<Text fw={600}>Delivery Detail</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && <BookingInfo record={activeRecord} />}
<Group justify="flex-end">
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
</Group>
</Stack>
</Modal>
{/* Trip slip modal */}
<Modal
opened={tripSlipOpen}
onClose={() => setTripSlipOpen(false)}
title={<Text fw={600}>Trip Slip</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{tripSlipRecord && <TripSlipDocument record={tripSlipRecord} />}
<Divider />
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setTripSlipOpen(false)}>Close</Button>
<Button leftSection={<Printer size={16} />} onClick={printTripSlip}>Print</Button>
</Group>
</Stack>
</Modal>
{/* Add Actual Distance modal */}
<Modal
opened={distanceOpen}
onClose={closeDistance}
title={<Text fw={600}>Add Actual Distance</Text>}
size="md"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Group justify="space-between">
<Text size="xs" c="dimmed">Customer</Text>
<Text size="sm">{customerName(activeRecord)}</Text>
</Group>
<Group justify="space-between">
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
</Group>
</Stack>
</Card>
)}
<NumberInput
label="Actual Distance (KM)"
placeholder="Enter distance"
value={distanceValue}
onChange={(v) => setDistanceValue(String(v ?? ""))}
min={0}
step={0.1}
decimalScale={2}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeDistance}>Cancel</Button>
<Button
onClick={handleSaveDistance}
loading={updateDistanceMutation.isPending}
disabled={!distanceValue}
>
Save
</Button>
</Group>
</Stack>
</Modal>
{/* Invoice modal */}
<Modal
opened={invoiceOpen}
onClose={closeInvoice}
title={<Text fw={600}>Invoice #345</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{invoiceRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700}>EDR Freight</Text>
<Text fw={600} size="sm">Invoice #345</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
<InfoRow label="Pickup" value={originYardName(invoiceRecord)} />
<InfoRow label="Destination" value={deliveryLocation(invoiceRecord)} />
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
</SimpleGrid>
<Divider />
<Group justify="flex-end">
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Post Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
</Group>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Advanced Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
</Group>
<Divider my="xs" />
{(() => {
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
const difference = postPayment - advancedPayment;
if (difference > 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Remaining to Pay</Text>
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
</Group>
);
} else if (difference < 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Refund</Text>
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
</Group>
);
} else {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Status</Text>
<Text fw={700} c="blue">Settled</Text>
</Group>
);
}
})()}
</Stack>
</Group>
</Stack>
</Card>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeInvoice}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default LastMilePage;