Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
natib21 16bd7fabcc mile
2026-07-03 20:21:16 +00:00

1938 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { type ReactNode, useMemo, useState } from "react";
import {
ArrowRight,
Boxes,
Eye,
MoreHorizontal,
Plus,
Printer,
Receipt,
RefreshCw,
Ruler,
Trash,
Truck,
X,
} 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,
Alert,
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Menu,
Modal,
MultiSelect,
NumberInput,
ScrollArea,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } 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 { driversService, type Driver } from "@/services/drivers.service";
import { ratesService } from "@/services/rates.service";
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
import { api } from "@/auth/http";
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(" · ");
};
/** One vehicle (with trailer) carries two containers. */
const CONTAINERS_PER_VEHICLE = 2;
const containerCount = (record: LastMileRecord) =>
(record.booking?.bookingContainers ?? []).reduce(
(sum, c) => sum + (Number(c.quantity) || 0),
0,
);
/** Trucks needed for a booking = ceil(containers / 2). 0 when no container data. */
const requiredVehicles = (record: LastMileRecord) => {
const n = containerCount(record);
return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0;
};
/** Container numbers on a booking, in line order (skips lines without one). */
const bookingContainerNumbers = (record: LastMileRecord): string[] =>
(record.booking?.bookingContainers ?? [])
.map((c) => c.containerNumber)
.filter((n): n is string => Boolean(n));
/** Container rows for the per-container→vehicle allocation table. */
const allocationRowsFor = (record: LastMileRecord): LastMileContainerRow[] =>
(record.booking?.bookingContainers ?? []).map((c) => ({
id: c.id,
type:
c.containerNumber ??
c.containerType?.code ??
c.containerType?.label ??
c.containerType?.name ??
(c.containerSize || "Container"),
qty: c.quantity || 1,
}));
/** Container badges for a booking: the container number when known, else the
* type × quantity. */
const containerLabels = (record: LastMileRecord): string[] => {
const out: string[] = [];
for (const c of record.booking?.bookingContainers ?? []) {
const size = c.containerSize ? ` · ${c.containerSize}` : "";
if (c.containerNumber) {
out.push(`${c.containerNumber}${size}`);
} else {
const type =
c.containerType?.code ??
c.containerType?.label ??
c.containerType?.name ??
(c.containerSize || "Container");
out.push(`${type} × ${c.quantity}`);
}
}
return out;
};
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
const fmtStamp = (iso?: string | null) => {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d.toLocaleString();
};
/**
* Derive the 6-step last-mile workflow state for a record. Step completion is
* read from the record + its pickup-ready (warehouse release) row:
* assign→vehicleId, arrived→release order issued, leave→releaseDate,
* in-transit/delivered→status, distance→exactKm.
*/
const computeLastMileSteps = (
record: LastMileRecord,
releaseRow?: ImportUnloadedItem,
): LastMileStepState[] => {
const exactKm = (record as { exactKm?: number | null }).exactKm;
// Truck arrival/leave live in the transient warehouse pickup-ready queue and
// vanish once the item is released. So once the leg is IN_TRANSIT/DELIVERED,
// treat both as done (the truck must have arrived + left to get there).
const past = record.status === "IN_TRANSIT" || record.status === "DELIVERED";
const flags = [
record.status !== "PAYMENT_PENDING",
Boolean(record.vehicleId),
past || Boolean(releaseRow?.releaseOrderReference),
past || Boolean(releaseRow?.releaseDate),
past,
exactKm != null,
exactKm != null, // Generate Invoice — auto-generated when distance is saved
record.status === "DELIVERED",
];
// Current step = earliest incomplete one.
const activeIdx = flags.findIndex((f) => !f);
const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Generate Invoice", "Delivered"];
const details: (string | null)[] = [
null,
record.vehicle?.plateNumber ?? null,
releaseRow?.releaseOrderReference ?? null,
fmtStamp(releaseRow?.releaseDate),
null,
exactKm != null ? `${exactKm} KM` : null,
exactKm != null ? "Invoice ready" : null,
fmtStamp(releaseRow?.deliveredAt),
];
return labels.map((label, i) => ({
label,
done: flags[i],
active: i === activeIdx,
detail: details[i],
}));
};
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 toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
({
id: row.id,
bookingId: row.bookingId,
quantity: 1,
weight: Number(row.weight) || 0,
grnNumber: row.grnNumber,
status: row.currentStatus,
arrivedAt: row.arrivalTime,
unloadedAt: row.arrivalTime,
inspectionStatus: row.inspectionStatus,
releaseDate: row.releaseDate,
releaseOrderReference: row.releaseOrderReference,
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
booking: row.bookingId
? {
id: row.bookingId,
reference: row.bookingReference ?? row.bookingId,
tradeDirection: "IMPORT",
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
customerTruckPlateNumber: row.customerTruckPlateNumber,
customerTruckDriverName: row.customerTruckDriverName,
customerTruckType: row.customerTruckType,
customerTruckContainerNumber: row.customerTruckContainerNumber,
customerTruckAssignedAt: row.customerTruckAssignedAt,
}
: null,
}) as unknown as WarehouseInventoryItem;
const releasePrefillFromLastMile = (
record: LastMileRecord,
row?: ImportUnloadedItem | null,
driversById?: Map<string, Driver>,
): ReleaseOrderTruckPrefill => {
const vehicle = record.vehicle;
const truckType = [vehicle?.manufacturer, vehicle?.model].filter(Boolean).join(" ").trim();
const assignedDriver = vehicle?.assignedDriverId ? driversById?.get(vehicle.assignedDriverId) : undefined;
const assignedDriverName = assignedDriver
? `${assignedDriver.firstName ?? ""} ${assignedDriver.lastName ?? ""}`.trim()
: "";
return {
truckPlateNumber: vehicle?.powerPlateNo || vehicle?.plateNumber || null,
trailerPlateNumber: vehicle?.trailerPlateNo || null,
driverName: vehicle?.assignedDriverName || assignedDriverName || null,
driverLicense: assignedDriver?.licenseNumber || null,
driverPhone: assignedDriver?.phoneNumber || null,
truckType: vehicle?.vehicleType || truckType || null,
containerNumber: row?.containerNumber ?? null,
};
};
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 }) => {
const hasDeliveryAddress = record.booking?.lastMileDeliveryAddress != null;
return (
<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)} />
{hasDeliveryAddress && <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);
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
const [vehicleRows, setVehicleRows] = useState<
Array<{ vehicleId: string | null; containerNumber: string }>
>([{ vehicleId: null, containerNumber: "" }]);
// 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 [acceptVehicleValues, setAcceptVehicleValues] = useState<string[]>([]);
const [arrivalSearch, setArrivalSearch] = useState("");
const [distanceOpen, setDistanceOpen] = useState(false);
// Per-vehicle actual distance, keyed by vehicleId.
const [distanceRows, setDistanceRows] = useState<Record<string, string>>({});
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
const [allocationOpen, setAllocationOpen] = useState(false);
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseTruckPrefill, setReleaseTruckPrefill] = useState<ReleaseOrderTruckPrefill | 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", "free"],
queryFn: async () => {
const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" });
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 existingLastMileBookingIds = useMemo(
() => new Set(records.map((record) => record.bookingId)),
[records],
);
const needsDriverLookup = records.some((record) => record.vehicle?.assignedDriverId);
const { data: driversData } = useQuery({
queryKey: ["drivers", "list", "ACTIVE"],
queryFn: async () => {
const res = await driversService.getAll({ status: "ACTIVE" });
return res.data;
},
enabled: needsDriverLookup,
});
const driversById = useMemo(
() => new Map((driversData ?? []).map((driver) => [driver.id, driver])),
[driversData],
);
const { data: pickupReadyRows = [] } = useQuery({
queryKey: ["warehouse-inventory", "import-pickup-ready-queue"],
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.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 });
void qc.invalidateQueries({ queryKey: ["vehicles"] });
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
},
});
const setVehiclesMutation = useMutation({
mutationFn: ({
id,
vehicles,
}: {
id: string;
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>;
}) => lastMileService.setVehicles(id, vehicles),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: ["vehicles"] });
},
onError: () => {
toast({ title: "Assign failed", variant: "destructive" });
},
});
const distanceMutation = useMutation({
mutationFn: ({
id,
distances,
remainingPayment,
}: {
id: string;
distances: Array<{ vehicleId: string; distanceKm: number }>;
remainingPayment?: number;
}) => lastMileService.setDistances(id, distances, remainingPayment),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
closeDistance();
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
},
});
const generateInvoiceMutation = useMutation({
mutationFn: (id: string) => lastMileService.generateInvoice(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
toast({ title: "Invoice generated" });
},
onError: () => {
toast({ title: "Invoice generation failed", variant: "destructive" });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => lastMileService.remove(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() });
toast({ title: "Record deleted", description: "Last-mile record removed successfully." });
},
onError: () => {
toast({ title: "Delete failed", variant: "destructive" });
},
});
const allocateMutation = useMutation({
mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) =>
api.post(`/last-mile/${activeId}/allocate-containers`, { allocations: data }),
onSuccess: () => {
toast({ title: "Containers allocated", variant: "default" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: ["vehicles"] });
closeAllocation();
},
onError: () => {
toast({ title: "Allocation 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(() => {
let filtered = arrivalQueue.filter((item) => !existingLastMileBookingIds.has(item.bookingId));
const term = arrivalSearch.trim().toLowerCase();
if (!term) return filtered;
return filtered.filter((item) =>
[item.bookingReference, item.customer, item.cargo, item.warehouse, item.yard]
.join(" ")
.toLowerCase()
.includes(term),
);
}, [arrivalQueue, arrivalSearch, existingLastMileBookingIds]);
const acceptMutation = useMutation({
mutationFn: async ({ items, vehicleIds }: { items: ArrivalQueueItem[]; vehicleIds: string[] }) => {
const created = await Promise.all(
items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)),
);
if (vehicleIds.length) {
const vehicles = vehicleIds.map((v) => ({ vehicleId: v }));
await Promise.all(created.map((record) => lastMileService.setVehicles(record.id, vehicles)));
}
return created;
},
onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: ["vehicles"] });
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([]);
setAcceptVehicleValues([]);
setArrivalSearch("");
};
const closeAccept = () => {
setAcceptOpen(false);
setAcceptStep(1);
setSelectedArrivalItems([]);
setAcceptVehicleValues([]);
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, vehicleIds: acceptVehicleValues });
};
const openDistance = (id: string) => {
const rec = records.find((r) => r.id === id);
const rows: Record<string, string> = {};
for (const a of rec?.vehicleAssignments ?? []) {
rows[a.vehicleId] = a.distanceKm != null ? String(a.distanceKm) : "";
}
setActiveId(id);
setDistanceRows(rows);
setDistanceOpen(true);
};
const closeDistance = () => {
setDistanceOpen(false);
setActiveId(null);
setDistanceRows({});
};
const openInvoice = (record: LastMileRecord) => {
setInvoiceRecord(record);
setInvoiceOpen(true);
};
const closeInvoice = () => {
setInvoiceOpen(false);
setInvoiceRecord(null);
};
const openAllocation = (id: string, containers?: LastMileContainerRow[]) => {
setActiveId(id);
setAllocationContainers(containers ?? []);
setAllocationOpen(true);
};
const closeAllocation = () => {
setAllocationOpen(false);
setActiveId(null);
setAllocationContainers([]);
};
const handleSaveDistance = () => {
const distances = Object.entries(distanceRows)
.map(([vehicleId, val]) => ({ vehicleId, distanceKm: parseFloat(val) }))
.filter((d) => !Number.isNaN(d.distanceKm) && d.distanceKm >= 0);
if (!activeId || !distances.length) {
toast({ title: "Invalid distance", description: "Enter a distance for at least one vehicle.", variant: "destructive" });
return;
}
const total = distances.reduce((s, d) => s + d.distanceKm, 0);
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) {
remainingPayment = total * parseFloat(lastMileRate.rateValue);
}
}
distanceMutation.mutate({ id: activeId, distances, remainingPayment });
};
const activeRecord = useMemo(
() => records.find((r) => r.id === activeId) ?? null,
[records, activeId],
);
// Vehicle picker options for the assign modal = free vehicles PLUS the ones
// already on this record (which are BUSY, so absent from the free list) so a
// reassign shows its current trucks selected instead of blank.
const assignVehicleOptions = useMemo(() => {
const opts = [...vehicleOptions];
const seen = new Set(opts.map((o) => o.value));
const current = [
...(activeRecord?.vehicleAssignments?.map((a) => a.vehicle) ?? []),
activeRecord?.vehicle,
];
for (const v of current) {
if (v && !seen.has(v.id)) {
seen.add(v.id);
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
if (v.code) parts.unshift(v.code);
opts.push({ value: v.id, label: parts.join(" · ") });
}
}
return opts;
}, [vehicleOptions, activeRecord]);
const pickupReadyByBooking = useMemo(() => {
const map = new Map<string, ImportUnloadedItem>();
for (const row of pickupReadyRows) {
if (row.bookingId) map.set(row.bookingId, row);
if (row.bookingReference) map.set(row.bookingReference, row);
}
return map;
}, [pickupReadyRows]);
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
);
const matchesFilter = (r: LastMileRecord) => {
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 (filterPostPaymentPending && !(r.remainingPayment > 0)) 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;
const rec = records.find((r) => r.id === resolved);
// Prefill each row's container number from the booking's container numbers
// (by order) when the assignment doesn't already carry one.
const nums = rec ? bookingContainerNumbers(rec) : [];
const rows =
rec?.vehicleAssignments?.length
? rec.vehicleAssignments.map((a, i) => ({
vehicleId: a.vehicleId,
containerNumber: a.containerNumber ?? nums[i] ?? "",
}))
: rec?.vehicleId
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
setBulkMode(false);
setActiveId(resolved);
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
setAssignOpen(true);
};
const openBulkAssign = () => {
setBulkMode(true);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
setAssignOpen(true);
};
const closeAssign = () => {
setAssignOpen(false);
setBulkMode(false);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
};
const handleAssign = () => {
const seen = new Set<string>();
const vehicles = vehicleRows
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
const count = vehicles.length;
const targetIds = bulkMode
? selectedIds
: [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id));
if (!targetIds.length) return;
// Empty set = unassign all (setVehicles releases the removed vehicles).
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
.then(() => {
toast({
title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned",
description:
count === 0
? bulkMode ? `${targetIds.length} deliveries` : undefined
: `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`,
});
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 openTruckArrival = (record: LastMileRecord) => {
if (!isAssigned(record)) {
toast({
title: "Assign a truck first",
description: "Truck arrival opens after a last-mile vehicle is assigned.",
variant: "destructive",
});
return;
}
const row = pickupReadyByBooking.get(record.bookingId) ?? pickupReadyByBooking.get(bookingRef(record));
if (!row) {
toast({
title: "Import inventory is not pickup-ready",
description: `${bookingRef(record)} must be unloaded and pass inspection before truck arrival.`,
variant: "destructive",
});
return;
}
setReleaseTruckPrefill(releasePrefillFromLastMile(record, row, driversById));
setReleaseItem(toReleaseInventoryItem(row));
};
const closeTruckArrival = () => {
setReleaseItem(null);
setReleaseTruckPrefill(null);
void qc.invalidateQueries({ queryKey: ["warehouse-inventory"] });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
};
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: "postPayment",
header: "Post Payment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatPrice(row.original.remainingPayment),
},
{
id: "vehicle",
header: "Vehicle",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const assigns = row.original.vehicleAssignments ?? [];
if (assigns.length > 1) {
const first = assigns[0]?.vehicle;
const firstLabel = first
? [first.code, first.plateNumber].filter(Boolean).join(" · ")
: "Vehicle";
return (
<Group gap={4} wrap="nowrap" style={{ whiteSpace: "nowrap" }}>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>{firstLabel}</Text>
<Badge size="sm" variant="light" color="blue">
+{assigns.length - 1}
</Badge>
</Group>
);
}
return vehicleLabel(row.original) ?? <Text c="dimmed">Unassigned</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;
const isPaid = (row.original as any).paid;
if (!hasDistance) {
return <Text c="dimmed"></Text>;
}
if (isPaid) {
return (
<Group gap="xs" wrap="nowrap">
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
<Badge color="green" variant="light" size="sm">Paid</Badge>
</Group>
);
}
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: "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";
const isPaid = (row.original as any).paid;
const delivered = row.original.status === "DELIVERED";
const releaseRow =
pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original));
// Gate on PERSISTENT state (status/vehicle/distance), not the truck
// arrival/leave signals — those live in the warehouse queue and vanish
// once the item is released, so they can't gate the status advance.
const status = row.original.status;
const hasDistance = row.original.exactKm != null;
// Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a
// vehicle), IN_TRANSIT→Delivered (needs distance/invoice).
const canAdvance =
status === "PAYMENT_PENDING" ||
(status === "READY_TO_TRANSIT" && assigned) ||
(status === "IN_TRANSIT" && hasDistance);
const canAssignStep = !assigned && status !== "DELIVERED";
const canDistance = status === "IN_TRANSIT";
// Truck arrival/leaving are independent — each driven only by its own
// warehouse state: arrive once assigned & not arrived, leave once
// arrived & not departed.
const canArrive = assigned && !releaseRow?.releaseOrderReference;
const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate;
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 || !canAdvance}
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={!canAssignStep}
onClick={() => openAssign(row.original.id)}
>
Assign
</Menu.Item>
<Menu.Item
leftSection={<RefreshCw size={15} />}
disabled={!assigned || delivered}
onClick={() => openAssign(row.original.id)}
>
Reassign
</Menu.Item>
<Menu.Item
color="red"
leftSection={<X size={15} />}
disabled={!assigned || delivered}
onClick={() =>
setVehiclesMutation.mutate(
{ id: row.original.id, vehicles: [] },
{
onSuccess: () =>
toast({ title: "Vehicles unassigned", description: bookingRef(row.original) }),
},
)
}
>
Unassign
</Menu.Item>
<Menu.Item
leftSection={<Boxes size={15} />}
disabled={allocationRowsFor(row.original).length === 0 || delivered}
onClick={() => openAllocation(row.original.id, allocationRowsFor(row.original))}
>
Allocate to trucks
</Menu.Item>
<Menu.Item
leftSection={<Truck size={15} />}
disabled={!canArrive}
onClick={() => openTruckArrival(row.original)}
>
Truck Arrival
</Menu.Item>
<Menu.Item
leftSection={<Truck size={15} />}
disabled={!canLeave}
onClick={() => openTruckArrival(row.original)}
>
Truck Leaving
</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} />}
disabled={!canDistance}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
onClick={() =>
generateInvoiceMutation.mutate(row.original.id, {
onSuccess: () => openInvoice(row.original),
})
}
>
Generate Invoice
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
onClick={() => handlePrintTripSlip(row.original)}
>
Print trip slip
</Menu.Item>
)}
{!isPaid && (
<>
<Menu.Divider />
<Menu.Item
leftSection={<Trash size={15} />}
color="red"
disabled={delivered}
onClick={() => {
if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) {
deleteMutation.mutate(row.original.id);
}
}}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vehicleOptions, pickupReadyByBooking]);
return (
<Stack gap="md" p="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 />
<MultiSelect
label="Assign Vehicles (optional)"
placeholder={
vehicleOptions.length === 0
? "No free vehicles"
: acceptVehicleValues.length === 0
? "Add vehicles"
: undefined
}
description={
vehicleOptions.length === 0
? "No free vehicles available — you can still accept and assign vehicles later."
: "Pick one or more trucks for this delivery."
}
data={vehicleOptions}
value={acceptVehicleValues}
onChange={setAcceptVehicleValues}
searchable
clearable
hidePickedOptions
disabled={vehicleOptions.length === 0}
/>
<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>
)}
{!bulkMode && activeRecord && (() => {
const containers = containerCount(activeRecord);
const needed = requiredVehicles(activeRecord);
const picked = vehicleRows.filter((r) => r.vehicleId).length;
if (needed === 0) {
return (
<Alert variant="light" color="gray" title="One truck (with trailer) carries 2 containers">
No container count on this booking assign trucks as needed.
</Alert>
);
}
const ok = picked === needed;
return (
<Alert
variant="light"
color={ok ? "green" : "yellow"}
title={`${containers} container${containers === 1 ? "" : "s"} · needs ${needed} vehicle${needed === 1 ? "" : "s"}`}
>
One truck (with trailer) carries {CONTAINERS_PER_VEHICLE} containers.
{picked > 0 && !ok &&
` You've selected ${picked}${picked < needed ? "add more" : "that's more than needed"}.`}
</Alert>
);
})()}
{!bulkMode && activeRecord && containerLabels(activeRecord).length > 0 && (
<Card withBorder padding="xs" radius="md" bg="var(--mantine-color-gray-0)">
<Text size="xs" fw={600} c="dimmed" mb={4}>
Containers ({containerLabels(activeRecord).length})
</Text>
<Group gap={6}>
{containerLabels(activeRecord).map((label, i) => (
<Badge key={i} size="sm" variant="outline" color="gray">
{label}
</Badge>
))}
</Group>
</Card>
)}
<Divider />
<Stack gap="xs">
{vehicleRows.map((row, i) => (
<Group key={i} gap="xs" wrap="nowrap" align="flex-end">
<Select
style={{ flex: 1.4 }}
label={i === 0 ? "Vehicle" : undefined}
placeholder={assignVehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
data={assignVehicleOptions.filter(
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
)}
value={row.vehicleId}
onChange={(v) =>
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
}
searchable
clearable
disabled={assignVehicleOptions.length === 0}
/>
<TextInput
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder="Container number"
value={row.containerNumber}
onChange={(e) => {
const value = e.currentTarget.value;
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)),
);
}}
/>
{vehicleRows.length > 1 && (
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove vehicle"
onClick={() => setVehicleRows((prev) => prev.filter((_, idx) => idx !== i))}
>
<X size={16} />
</ActionIcon>
)}
</Group>
))}
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() =>
setVehicleRows((prev) => [
...prev,
{
vehicleId: null,
containerNumber:
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
},
])
}
disabled={
assignVehicleOptions.length === 0 ||
vehicleRows.some((r) => !r.vehicleId) ||
vehicleRows.filter((r) => r.vehicleId).length >= assignVehicleOptions.length
}
style={{ alignSelf: "flex-start" }}
>
Add vehicle
</Button>
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>Cancel</Button>
<Button
onClick={handleAssign}
loading={setVehiclesMutation.isPending}
disabled={bulkMode ? selectedIds.length === 0 : !activeRecord}
>
{!bulkMode && activeRecord && isAssigned(activeRecord) ? "Update vehicles" : "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} />}
{activeRecord && (activeRecord.vehicleAssignments?.length ?? 0) > 0 && (
<Card withBorder padding="md" radius="md">
<Text fw={600} size="sm" mb="sm">Assigned vehicles</Text>
<Stack gap="xs">
{activeRecord.vehicleAssignments!.map((a) => {
const v = a.vehicle;
const label = v
? [v.code, v.plateNumber].filter(Boolean).join(" · ")
: a.vehicleId;
return (
<Group key={a.id} justify="space-between" wrap="nowrap">
<Text size="sm">{label}</Text>
{a.containerNumber ? (
<Badge size="sm" variant="light" color="blue">
{a.containerNumber}
</Badge>
) : (
<Text size="xs" c="dimmed">No container no.</Text>
)}
</Group>
);
})}
</Stack>
</Card>
)}
{activeRecord && (
<Card withBorder padding="md" radius="md">
<Text fw={600} size="sm" mb="sm">Delivery steps</Text>
<LastMileStepper
steps={computeLastMileSteps(
activeRecord,
pickupReadyByBooking.get(activeRecord.bookingId) ??
pickupReadyByBooking.get(bookingRef(activeRecord)),
)}
/>
</Card>
)}
<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)">
<Group justify="space-between">
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Text size="xs" c="dimmed">Est. {activeRecord.estimatedKm ?? "—"} km</Text>
</Group>
</Card>
)}
{(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? (
<Text size="sm" c="dimmed">Assign a vehicle before entering distance.</Text>
) : (
<Stack gap="sm">
{activeRecord!.vehicleAssignments!.map((a) => {
const v = a.vehicle;
const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
return (
<NumberInput
key={a.id}
label={`${label}${a.containerNumber ? ` · ${a.containerNumber}` : ""}`}
placeholder="Distance (km)"
value={distanceRows[a.vehicleId] ?? ""}
onChange={(val) =>
setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") }))
}
min={0}
step={0.1}
decimalScale={2}
/>
);
})}
<Group justify="space-between">
<Text size="xs" c="dimmed">Total</Text>
<Text size="sm" fw={600}>
{Object.values(distanceRows)
.reduce((s, val) => s + (parseFloat(val) || 0), 0)
.toFixed(2)}{" "}
km
</Text>
</Group>
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeDistance}>Cancel</Button>
<Button
onClick={handleSaveDistance}
loading={distanceMutation.isPending}
disabled={Object.values(distanceRows).every((v) => !v)}
>
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>
{/* Container Allocation modal */}
<Modal
opened={allocationOpen}
onClose={closeAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Stack gap={0}>
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Text size="xs" c="dimmed">{customerName(activeRecord)}</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed" tt="uppercase">Cargo Type</Text>
<Text size="sm" fw={600}>{activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"}</Text>
</Stack>
</Group>
</Stack>
</Card>
{/* Capacity logic based on cargo type */}
{activeRecord.booking?.cargoType?.name === "BULK" ? (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-blue-0)" style={{ borderColor: "var(--mantine-color-blue-3)" }}>
<Stack gap="sm">
<Group gap="xs">
<Text fw={600} size="sm">Smart Capacity Allocation</Text>
</Group>
<Stack gap={2}>
<Text size="sm">Capacity: TBD</Text>
<Text size="xs" c="dimmed">
TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
<Text size="xs" c="dimmed">
TODO: add container weight to booking if missing
</Text>
</Stack>
<Text size="sm" fw={500} mt="xs">
Select multiple containers per vehicle based on capacity
</Text>
</Stack>
</Card>
) : (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Text size="sm" fw={500}>Up to 2 containers per vehicle (trailer)</Text>
</Card>
)}
</>
)}
<LastMileContainerAllocationTable
containers={allocationContainers}
onSave={async (mappings) => {
await allocateMutation.mutateAsync(mappings);
}}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
<ReleaseOrderModal
opened={Boolean(releaseItem)}
onClose={closeTruckArrival}
item={releaseItem}
truckPrefill={releaseTruckPrefill}
/>
</Stack>
);
};
export default LastMilePage;