Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
2026-08-13 13:46:46 +00:00

2348 lines
94 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,
Eye,
MoreHorizontal,
Plus,
Printer,
Receipt,
RefreshCw,
Ruler,
Trash,
FileText,
Truck,
X,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
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,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse";
import { warehouseService } from "@/services/warehouse.service";
import { bookingsService } from "@/services/bookings.service";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { LastMileRequestsPanel } from "@/components/operations/LastMileRequestsPanel";
import {
LAST_MILE_STATUSES,
type LastMileApiStatus,
type LastMileRecord,
type LastMileVehicle,
lastMileService,
} from "@/services/last-mile.service";
import { vehiclesService } from "@/services/vehicles.service";
import { driversService, type Driver } from "@/services/drivers.service";
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
import { toReleaseInventoryItem } from "@/components/warehouses/options";
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
`${currency || "ETB"} ${(Number(amount) || 0).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" },
};
// Middle-truncate a long invoice number for the table (full value on hover).
// "INV-20260704-00002" → "INV-2…02"
const shortInvoiceNo = (n: string) =>
n && n.length > 9 ? `${n.slice(0, 5)}${n.slice(-2)}` : n;
// Invoice payment state → badge color, keyed by upper-cased status.
const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
PAID: { label: "Paid", color: "green" },
PARTIALLY_PAID: { label: "Partially Paid", color: "teal" },
PENDING: { label: "Pending", color: "yellow" },
PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" },
UNPAID: { label: "Unpaid", color: "yellow" },
OPEN: { label: "Open", color: "yellow" },
ISSUED: { label: "Issued", color: "blue" },
OVERDUE: { label: "Overdue", color: "red" },
CANCELLED: { label: "Cancelled", color: "gray" },
VOID: { label: "Void", color: "gray" },
};
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, by container SIZE: a 40ft fills a truck (1 each),
* two 20ft share one. Falls back to ceil(n / 2) when no size is recorded.
* 0 when the booking has no container data (bulk).
*/
const requiredVehicles = (record: LastMileRecord) => {
const lines = record.booking?.bookingContainers ?? [];
if (!containerCount(record)) return 0;
let forty = 0;
let others = 0;
for (const c of lines) {
const qty = Number(c.quantity) || 0;
if ((c.containerSize ?? '').includes('40')) forty += qty;
else others += qty;
}
return forty + Math.ceil(others / CONTAINERS_PER_VEHICLE);
};
/** Real per-physical-container numbers on a booking, in order. Prefers each
* line's `units` (the actual numbers) over the line-level number (often a
* "TBD-…" placeholder). One entry per physical container, for per-truck prefill. */
const bookingContainerNumbers = (record: LastMileRecord): string[] => {
const out: string[] = [];
const real = (n?: string | null): n is string =>
Boolean(n) && !/^TBD/i.test(n!.trim());
for (const c of record.booking?.bookingContainers ?? []) {
const units = [...(c.units ?? [])].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
);
if (units.length) {
for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber!);
} else if (real(c.containerNumber)) {
out.push(c.containerNumber!);
}
}
return out;
};
/** 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);
// Paid = record flag set OR its invoice reached PAID.
const isPaidRecord = (r: LastMileRecord) =>
Boolean((r as { paid?: boolean }).paid) ||
(r.invoice?.status ?? "").toUpperCase() === "PAID";
// Post payment pending = a post payment is owed but not yet paid.
const isPostPaymentPending = (r: LastMileRecord) =>
Number(r.remainingPayment) > 0 && !isPaidRecord(r);
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 currencyOf = (r: LastMileRecord) =>
r.vehicle?.currency ??
r.vehicleAssignments?.[0]?.vehicle?.currency ??
r.booking?.paymentCurrency ??
"ETB";
type LmAssignment = NonNullable<LastMileRecord["vehicleAssignments"]>[number];
const truckShort = (a: LmAssignment) =>
a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */
const billingIssues = (r: LastMileRecord) => {
const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0);
const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort);
const currencies = [
...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))),
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
/**
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
* vehicle missing either field cannot produce an invoice line. Returns the
* human-readable gap, or null when the vehicle is billable.
*/
const pricingGap = (
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
): string | null => {
if (!v) return null;
const missing: string[] = [];
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
if (!String(v.currency ?? "").trim()) missing.push("Currency");
return missing.length ? missing.join(" and ") : null;
};
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 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, currencyOf(record))} />
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment, currencyOf(record))} />
<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>
);
};
type TripSlipVehicle = NonNullable<LastMileRecord["vehicleAssignments"]>[number];
const tripSlipRows = (
record: LastMileRecord,
vehicle?: TripSlipVehicle | null,
): [string, string][] => {
// Per-vehicle block when a specific truck is chosen (its own driver, container(s)
// and distance); else fall back to the record-level vehicle summary.
const vehicleRows: [string, string][] = vehicle
? [
[
"Vehicle",
vehicle.vehicle
? [vehicle.vehicle.code, vehicle.vehicle.plateNumber].filter(Boolean).join(" · ")
: vehicle.vehicleId,
],
["Driver", vehicle.vehicle?.assignedDriverName || "—"],
[
"Container(s)",
vehicle.containerNumber || bookingContainerNumbers(record).join(", ") || "—",
],
["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"],
]
: [
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"],
];
return [
["Customer", customerName(record)],
["Service", serviceTypeName(record)],
["Pickup (origin yard)", originYardName(record)],
["Destination", deliveryLocation(record)],
["Cargo", cargoDesc(record)],
["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))],
...vehicleRows,
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
["Requested date", requestedDate(record)],
["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,
vehicle,
}: {
record: LastMileRecord;
vehicle?: TripSlipVehicle | null;
}) => (
<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, vehicle).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, vehicle?: TripSlipVehicle | null) => {
const rows = tripSlipRows(record, vehicle)
.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>
<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 { user } = useAuth();
const canViewRequests = hasPermission(user, FREIGHT_PERMS.lastMile.requestView);
const [view, setView] = useState<"legs" | "requests">("legs");
const [podRecord, setPodRecord] = useState<LastMileRecord | null>(null);
const navigate = useNavigate();
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);
// Which vehicle the trip slip is for (per-truck), + the pre-print picker.
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
// One row per truck. A truck carries one 40ft or up to two 20ft, so the load
// is a list, not a single container.
const [vehicleRows, setVehicleRows] = useState<
Array<{ vehicleId: string | null; containerNumbers: string[] }>
>([{ vehicleId: null, containerNumbers: [] }]);
// 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>>({});
// Record pending invoice-generation confirmation (shows a summary first).
const [invoiceConfirm, setInvoiceConfirm] = useState<LastMileRecord | null>(null);
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 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: (e: unknown) => {
// Surface the backend reason (e.g. "Truck … has no assigned driver …").
const raw = (e as { response?: { data?: { message?: string | string[] } } })?.response?.data
?.message;
const description = Array.isArray(raw) ? raw.join(", ") : raw;
toast({ title: "Assign failed", description, 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: (res) => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
const updated = res?.data as LastMileRecord | undefined;
closeDistance();
// Every truck has a distance and it isn't billed yet → offer to invoice now.
const trucks = updated?.vehicleAssignments ?? [];
const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0);
if (updated && allFilled && !updated.invoice) {
setInvoiceConfirm(updated);
}
},
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 { 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 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;
}
// Amount is computed server-side per truck (distance × the vehicle's
// price/km, in the vehicle's currency) — no flat LAST_MILE rate.
distanceMutation.mutate({ id: activeId, distances });
};
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 pushVehicle = (v?: LastMileVehicle | null) => {
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(" · ") });
}
};
for (const a of activeRecord?.vehicleAssignments ?? []) pushVehicle(a.vehicle);
pushVehicle(activeRecord?.vehicle);
// Fallback: an assigned vehicle whose relation didn't load still needs an
// option so the reassign Select can render it as selected (not blank).
for (const a of activeRecord?.vehicleAssignments ?? []) {
if (!seen.has(a.vehicleId)) {
seen.add(a.vehicleId);
opts.push({
value: a.vehicleId,
label: a.containerNumber ? `Assigned · ${a.containerNumber}` : "Assigned vehicle",
});
}
}
if (activeRecord?.vehicleId && !seen.has(activeRecord.vehicleId)) {
opts.push({ value: activeRecord.vehicleId, label: "Assigned vehicle" });
}
return opts;
}, [vehicleOptions, activeRecord]);
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
// below rather than silently billing 0 once distances are entered.
const pricingGapById = useMemo(() => {
const map = new Map<string, string | null>();
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
if (v?.id) map.set(v.id, pricingGap(v));
};
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
add(activeRecord?.vehicle);
return map;
}, [vehiclesData, activeRecord]);
const vehicleLabelFor = (id: string) =>
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
// Full booking (with container units) for the assign modal's container dropdown.
// Fetched on open so container numbers show regardless of what the list embeds.
const { data: assignBooking } = useQuery({
queryKey: activeRecord?.bookingId
? QUERY_KEYS.BOOKINGS.byId(activeRecord.bookingId)
: ["bookings", "detail", "none"],
queryFn: () => bookingsService.getById(activeRecord!.bookingId),
enabled: assignOpen && !bulkMode && Boolean(activeRecord?.bookingId),
});
// Container-number options for the dropdown = the booking's real per-container
// numbers (units), falling back to whatever the list record carried.
const containerOptions = useMemo(() => {
const real = (n?: string | null): n is string =>
Boolean(n) && !/^TBD/i.test(n!.trim());
const out: string[] = [];
for (const c of assignBooking?.bookingContainers ?? []) {
const units = [...(c.units ?? [])].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
);
if (units.length) {
for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber);
} else if (real(c.containerNumber)) {
out.push(c.containerNumber);
}
}
return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : [];
}, [assignBooking, activeRecord]);
// Container number → size ("20ft"/"40ft"), driving the per-truck cap: a 40ft
// fills the truck alone; two 20ft may share (no size mixing).
const sizeByNumber = useMemo(() => {
const map = new Map<string, string>();
const lines = assignBooking?.bookingContainers?.length
? assignBooking.bookingContainers
: activeRecord?.booking?.bookingContainers ?? [];
for (const line of lines) {
// The two payload shapes differ: the list record carries `containerSize`,
// the booking detail exposes the size on its container type.
const c = line as {
containerSize?: string | null;
containerNumber?: string | null;
containerType?: { code?: string; label?: string; sizeFt?: number };
units?: Array<{ containerNumber?: string | null }>;
};
const size = String(
c.containerSize ?? c.containerType?.sizeFt ?? c.containerType?.code ?? c.containerType?.label ?? "",
);
for (const u of c.units ?? []) {
if (u.containerNumber) map.set(u.containerNumber, size);
}
if (c.containerNumber) map.set(c.containerNumber, size);
}
return map;
}, [assignBooking, activeRecord]);
const is40 = (n: string) => (sizeByNumber.get(n) ?? "").includes("40");
// Trucks that already arrived/left keep their load locked — the API rejects
// changing or removing them; the modal greys those rows out.
const lockedVehicles = useMemo(() => {
const map = new Map<string, string>();
for (const a of activeRecord?.vehicleAssignments ?? []) {
if (a.departedAt) map.set(a.vehicleId, "left the warehouse");
else if (a.arrivedAt) map.set(a.vehicleId, "arrived at the warehouse");
}
return map;
}, [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 postPaymentPendingCount = useMemo(
() => records.filter(isPostPaymentPending).length,
[records],
);
// Billing problems on the leg pending invoice confirmation.
const confirmIssues = invoiceConfirm
? billingIssues(invoiceConfirm)
: { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] };
const filteredRecords = useMemo(() => {
const term = search.trim().toLowerCase();
return records.filter((r) => {
if (!matchesFilter(r)) return false;
if (filterPostPaymentPending && !isPostPaymentPending(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]);
// Per-truck exit papers for an EDR delivery (one paper per assigned truck).
const [exitPapersOpen, setExitPapersOpen] = useState(false);
const [exitPapersRecord, setExitPapersRecord] = useState<LastMileRecord | null>(null);
// Bulk drawdown: how much tonnage is still to be hauled on the booking being
// assigned. Bulk has no containers, so trucks keep going until this hits 0.
const assignBookingId = activeRecord?.booking?.id ?? null;
const { data: remainingTons } = useQuery({
queryKey: ["last-mile", "remaining-tons", assignBookingId],
queryFn: () => lastMileService.remainingTons(assignBookingId as string).then((r) => r.data),
enabled: assignOpen && !bulkMode && Boolean(assignBookingId),
});
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) : [];
// Prefer the truck's own container list; fall back to the legacy scalar, then
// to the booking's containers by order.
const loadOf = (
a: { containers?: Array<{ containerNumber: string }>; containerNumber?: string | null },
i: number,
) =>
a.containers?.length
? a.containers.map((c) => c.containerNumber)
: a.containerNumber
? [a.containerNumber]
: nums[i]
? [nums[i]]
: [];
const rows =
rec?.vehicleAssignments?.length
? rec.vehicleAssignments.map((a, i) => ({
vehicleId: a.vehicleId,
containerNumbers: loadOf(a, i),
}))
: rec?.vehicleId
? [{ vehicleId: rec.vehicleId, containerNumbers: nums[0] ? [nums[0]] : [] }]
: [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }];
setBulkMode(false);
setActiveId(resolved);
setVehicleRows(
rows.length ? rows : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }],
);
setAssignOpen(true);
};
const openBulkAssign = () => {
// A single selection has full booking context (details, container list) —
// use the richer single-record flow instead of the blank bulk form.
if (selectedIds.length === 1) {
openAssign(selectedIds[0]);
return;
}
setBulkMode(true);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
setAssignOpen(true);
};
const closeAssign = () => {
setAssignOpen(false);
setBulkMode(false);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
};
const handleAssign = () => {
const seen = new Set<string>();
const vehicles = vehicleRows
.filter((r): r is { vehicleId: string; containerNumbers: string[] } => Boolean(r.vehicleId))
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
.map((r) => ({
vehicleId: r.vehicleId,
containerNumbers: r.containerNumbers.map((n) => n.trim()).filter(Boolean),
}));
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;
// A 40ft container fills its truck — backstop for pre-filled reassignment
// rows the MultiSelect guard never saw.
const overloaded = vehicles.filter(
(v) => v.containerNumbers.length > 1 && v.containerNumbers.some(is40),
);
if (overloaded.length) {
toast({
title: "40ft fills the truck",
description: `${overloaded
.map((v) => vehicleLabelFor(v.vehicleId))
.join("; ")} — a 40ft container travels alone.`,
variant: "destructive",
});
return;
}
// Backstop for rows the Select guard never saw (pre-filled reassignments).
const unpriced = vehicles
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
if (unpriced.length) {
toast({
title: "Vehicle is not priced",
description: `${unpriced
.map((v) => `${v.label} (${v.gap} not set)`)
.join("; ")} — set it on the vehicle before assigning.`,
variant: "destructive",
});
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;
// Completing a delivery requires proof of delivery — open the capture modal
// instead of advancing straight to DELIVERED.
if (next === "DELIVERED") {
setPodRecord(record);
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) => {
// Always open the picker so the operator chooses which truck to print.
setTripSlipRecord(record);
setTripSlipVehicleId(null);
setTripSlipSelectOpen(true);
};
const printBookingSlip = () => {
setTripSlipVehicleId(null);
setTripSlipSelectOpen(false);
setTripSlipOpen(true);
};
const chooseTripSlipVehicle = (vehicleId: string) => {
setTripSlipVehicleId(vehicleId);
setTripSlipSelectOpen(false);
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));
};
// Truck leaving the warehouse = the leg is now in transit. Advance the status
// (same as "Mark In Transit") alongside the warehouse exit-weighing flow.
const handleTruckLeaving = (record: LastMileRecord) => {
openTruckArrival(record);
if (record.status === "READY_TO_TRANSIT") {
updateMutation.mutate({ id: record.id, data: { status: "IN_TRANSIT" } });
}
};
const closeTruckArrival = () => {
setReleaseItem(null);
setReleaseTruckPrefill(null);
void qc.invalidateQueries({ queryKey: ["warehouse-inventory"] });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
};
const tripSlipVehicle =
tripSlipRecord?.vehicleAssignments?.find((a) => a.vehicleId === tripSlipVehicleId) ?? null;
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, tripSlipVehicle));
win.document.close();
win.focus();
// Explicit print after the doc paints (onload can miss with document.write).
setTimeout(() => {
try {
win.print();
} catch {
/* window may have been closed */
}
}, 250);
};
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 }) => (
<Text size="sm" truncate maw={200}>
{customerName(row.original)}
</Text>
),
},
{
id: "postPayment",
header: "Post Payment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatPrice(row.original.remainingPayment, currencyOf(row.original)),
},
{
id: "vehicle",
header: "Vehicle",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const assigns = row.original.vehicleAssignments ?? [];
if (assigns.length > 1) {
const labelFor = (a: (typeof assigns)[number]) => {
const v = a.vehicle;
const l = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
return a.containerNumber ? `${l} · ${a.containerNumber}` : l;
};
return (
<Tooltip
withArrow
multiline
label={
<div style={{ whiteSpace: "pre-line" }}>
{assigns.map(labelFor).join("\n")}
</div>
}
>
<Group gap={4} wrap="nowrap" style={{ whiteSpace: "nowrap" }}>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{assigns[0].vehicle
? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ")
: assigns[0].vehicleId}
</Text>
<Badge size="sm" variant="light" color="blue">
+{assigns.length - 1}
</Badge>
</Group>
</Tooltip>
);
}
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} km` : <Text c="dimmed"></Text>,
},
{
id: "invoice",
header: "Invoice",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
// Only show an invoice once it's actually been generated — NOT merely
// because distance was entered.
const invoice = row.original.invoice;
if (!invoice) {
return <Text c="dimmed"></Text>;
}
const status = String((row.original as any).paid ? "PAID" : invoice.status || "").toUpperCase();
const badge = INVOICE_STATUS_META[status] ?? { color: "gray", label: status || "—" };
return (
<Group gap="xs" wrap="nowrap">
<UnstyledButton
onClick={() =>
invoice.id
? navigate(`/dashboard/invoices/${invoice.id}`)
: toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" })
}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
<Tooltip label={invoice.number} withArrow>
<span>{shortInvoiceNo(invoice.number)}</span>
</Tooltip>
</UnstyledButton>
{status && <Badge color={badge.color} variant="filled" size="sm">{badge.label}</Badge>}
</Group>
);
},
},
{
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);
// Assign stays active until the whole load has trucks: container
// bookings until every container is on a truck; bulk until the
// tonnage is drawn down (trucks depart one by one). Already-departed
// trucks keep their rows locked in the modal.
const totalContainers = containerCount(row.original);
const assignedContainers = (row.original.vehicleAssignments ?? []).reduce(
(s, a) => s + (a.containers?.length ?? (a.containerNumber ? 1 : 0)),
0,
);
const containersRemain = totalContainers > 0 && assignedContainers < totalContainers;
const bulkCargo = totalContainers === 0;
const canAssignStep =
status !== "DELIVERED" &&
!row.original.invoice &&
(!assigned || containersRemain || (bulkCargo && status !== "IN_TRANSIT"));
const canDistance = status === "IN_TRANSIT";
// Truck arrival/leaving are independent — each driven by its own
// warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED.
const pastTransit = status === "IN_TRANSIT" || status === "DELIVERED";
const canArrive = assigned && !releaseRow?.releaseOrderReference && !pastTransit;
const canLeave =
Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit;
return (
<Group gap={4} justify="flex-end" wrap="nowrap">
{pastTransit && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<Receipt size={13} />}
onClick={() => setDetentionRecord(row.original)}
>
Detention
</Button>
)}
<Menu
position="bottom-end"
width={200}
withinPortal
styles={{ dropdown: { maxHeight: 320, overflowY: "auto" } }}
>
<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 || Boolean(row.original.invoice)}
onClick={() => openAssign(row.original.id)}
>
Reassign
</Menu.Item>
<Menu.Item
color="red"
leftSection={<X size={15} />}
disabled={!assigned || delivered || Boolean(row.original.invoice)}
onClick={() =>
setVehiclesMutation.mutate(
{ id: row.original.id, vehicles: [] },
{
onSuccess: () =>
toast({ title: "Vehicles unassigned", description: bookingRef(row.original) }),
},
)
}
>
Unassign
</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={() => handleTruckLeaving(row.original)}
>
Truck Leaving
</Menu.Item>
<Menu.Item
leftSection={<FileText size={15} />}
disabled={!row.original.vehicleAssignments?.length}
onClick={() => {
setExitPapersRecord(row.original);
setExitPapersOpen(true);
}}
>
Truck exit papers
</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 || Boolean(row.original.invoice)}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={
!(row.original.exactKm != null && row.original.exactKm > 0) ||
Boolean(row.original.invoice)
}
onClick={() => setInvoiceConfirm(row.original)}
>
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
</Menu.Item>
{/* Truck detention: set/adjust arrival & return times, preview the
per-truck-per-day charge, and generate its invoice. Available
once the vehicle is en route/delivered (clock has a start). */}
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!pastTransit}
onClick={() => setDetentionRecord(row.original)}
>
Truck detention
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
onClick={() => handlePrintTripSlip(row.original)}
>
Print trip slip
</Menu.Item>
)}
{!isPaid && (
<>
<Menu.Divider />
<Menu.Item
leftSection={<Trash size={15} />}
color="red"
disabled={delivered || Boolean(row.original.invoice)}
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">
{canViewRequests && (
<Group gap="xs">
<Button
size="xs"
variant={view === "legs" ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setView("legs")}
>
Deliveries
</Button>
<Button
size="xs"
variant={view === "requests" ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setView("requests")}
>
Requests
</Button>
</Group>
)}
{view === "requests" && canViewRequests ? (
<LastMileRequestsPanel />
) : (
<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 ({postPaymentPendingCount})
</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) {
// Bulk: no containers — trucks haul loose tonnage until the
// booking's total is drawn down to zero by departing trucks.
const done = remainingTons?.complete;
return (
<Alert
variant="light"
color={done ? "green" : remainingTons ? "blue" : "gray"}
title={
remainingTons
? `${remainingTons.remainingTons} t remaining of ${remainingTons.totalTons} t`
: "No container count on this booking"
}
>
{remainingTons
? done
? "Fully hauled — no tonnage left to assign trucks for."
: `Bulk booking: ${remainingTons.hauledTons} t hauled so far. Keep assigning trucks until the remaining tonnage reaches 0 — each truck's net weight is deducted when it leaves.`
: "Assign trucks as needed."}
</Alert>
);
}
const coveredContainers = vehicleRows.reduce(
(s, r) => s + (r.vehicleId ? r.containerNumbers.length : 0),
0,
);
const ok = picked === needed && coveredContainers === containers;
return (
<Alert
variant="light"
color={ok ? "green" : "yellow"}
title={`${coveredContainers} of ${containers} container${containers === 1 ? "" : "s"} on trucks · needs ${needed} vehicle${needed === 1 ? "" : "s"}`}
>
One 40ft container fills a truck; two 20ft share one (no size mixing).
{containers - coveredContainers > 0 &&
` ${containers - coveredContainers} container${containers - coveredContainers === 1 ? "" : "s"} still unassigned — keep adding trucks.`}
{picked > 0 && picked !== needed &&
` You've selected ${picked} vehicle${picked === 1 ? "" : "s"}${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) => {
const lockReason = row.vehicleId ? lockedVehicles.get(row.vehicleId) : undefined;
const rowLocked = Boolean(lockReason);
const rowHas40 = row.containerNumbers.some(is40);
return (
<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) => {
const gap = v ? pricingGapById.get(v) : null;
if (v && gap) {
toast({
title: "Vehicle is not priced",
description: `${vehicleLabelFor(v)}${gap} not set. Set it on the vehicle before assigning.`,
variant: "destructive",
});
return;
}
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
}}
searchable
clearable
disabled={assignVehicleOptions.length === 0 || rowLocked}
/>
<MultiSelect
style={{ flex: 1 }}
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
placeholder={containerOptions.length ? "Select containers" : "No container numbers"}
description={rowLocked ? `Locked — truck ${lockReason}` : undefined}
// A 40ft container fills the truck alone; two 20ft may share.
maxValues={rowHas40 ? 1 : 2}
data={[
...containerOptions.filter(
(n) =>
row.containerNumbers.includes(n) ||
// a container rides exactly one truck …
(!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)) &&
// … and no size mixing: once a 20ft is picked a 40ft
// can't join it, and a 40ft truck is already full.
!(rowHas40 || (row.containerNumbers.length > 0 && is40(n)))),
),
// keep manual/legacy values selectable even if not in the booking
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
]}
value={row.containerNumbers}
onChange={(value) => {
// Guard the paste/keyboard path too — data filtering only
// covers the dropdown.
if (value.filter(is40).length > 0 && value.length > 1) {
toast({
title: "40ft fills the truck",
description: "A 40ft container travels alone — remove the other container.",
variant: "destructive",
});
return;
}
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
);
}}
searchable
clearable
disabled={rowLocked}
/>
{vehicleRows.length > 1 && !rowLocked && (
<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) => {
// Suggest the next unassigned container for the new truck.
const taken = new Set(prev.flatMap((r) => r.containerNumbers));
const next = (activeRecord ? bookingContainerNumbers(activeRecord) : []).find(
(n) => !taken.has(n),
);
return [...prev, { vehicleId: null, containerNumbers: next ? [next] : [] }];
})
}
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} vehicle={tripSlipVehicle} />}
<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>
{/* Trip slip — pick a vehicle (multi-truck) */}
<Modal
opened={tripSlipSelectOpen}
onClose={() => setTripSlipSelectOpen(false)}
title={<Text fw={600}>Print trip slip select vehicle</Text>}
radius="lg"
centered
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{tripSlipRecord ? bookingRef(tripSlipRecord) : ""} pick a truck to print its slip.
</Text>
{(tripSlipRecord?.vehicleAssignments ?? []).map((a) => {
const v = a.vehicle;
const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
return (
<Card
key={a.id}
withBorder
radius="md"
padding="sm"
onClick={() => chooseTripSlipVehicle(a.vehicleId)}
style={{ cursor: "pointer" }}
className="hover:bg-gray-50"
>
<Group justify="space-between" wrap="nowrap" align="center">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Truck size={15} />
<Text size="sm" fw={600} truncate>{label}</Text>
</Group>
<Text size="xs" c="dimmed" truncate>
Driver: {v?.assignedDriverName || "—"}
</Text>
<Text size="xs" c="dimmed" truncate>
Container: {a.containerNumber || "—"}
</Text>
</Stack>
<ActionIcon variant="light" color="edr-green" size="lg" aria-label="Print">
<Printer size={16} />
</ActionIcon>
</Group>
</Card>
);
})}
{(tripSlipRecord?.vehicleAssignments?.length ?? 0) === 0 && (
<Text size="sm" c="dimmed" ta="center" py="xs">No vehicles assigned yet.</Text>
)}
<Divider label="or" labelPosition="center" />
<Button variant="subtle" size="sm" onClick={printBookingSlip}>
Print booking slip (no vehicle)
</Button>
</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>
{/* Generate Invoice — confirmation summary */}
<Modal
opened={Boolean(invoiceConfirm)}
onClose={() => setInvoiceConfirm(null)}
title={<Text fw={600}>Generate Invoice</Text>}
radius="lg"
centered
>
{invoiceConfirm && (
<Stack gap="md">
<Group justify="space-between">
<Text fw={600} size="sm">{bookingRef(invoiceConfirm)}</Text>
<Text size="sm" c="dimmed">{customerName(invoiceConfirm)}</Text>
</Group>
<Card withBorder padding="sm" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap={6}>
{(invoiceConfirm.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}
{a.containerNumber ? ` · ${a.containerNumber}` : ""}
</Text>
<Text size="sm">{a.distanceKm != null ? `${a.distanceKm} km` : "—"}</Text>
</Group>
);
})}
</Stack>
</Card>
<Group justify="space-between">
<Text size="sm" c="dimmed">Total distance</Text>
<Text size="sm" fw={500}>{invoiceConfirm.exactKm ?? 0} km</Text>
</Group>
<Group justify="space-between">
<Text fw={600}>Invoice amount</Text>
<Text fw={700}>{formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))}</Text>
</Group>
{confirmIssues.mixedCurrency && (
<Alert color="red" variant="light" title="Mixed truck currencies">
Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing.
</Alert>
)}
{confirmIssues.zeroPrice.length > 0 && (
<Alert color="yellow" variant="light" title="Truck has no price/km">
{confirmIssues.zeroPrice.join(", ")} will bill 0 set Price per KM on the vehicle.
</Alert>
)}
<Text size="xs" c="dimmed">
This creates the delivery-fee invoice. Confirm the distances and amount are correct.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setInvoiceConfirm(null)}>Cancel</Button>
<Button
loading={generateInvoiceMutation.isPending}
disabled={confirmIssues.mixedCurrency}
onClick={() =>
generateInvoiceMutation.mutate(invoiceConfirm.id, {
onSuccess: () => setInvoiceConfirm(null),
})
}
>
Generate Invoice
</Button>
</Group>
</Stack>
)}
</Modal>
<ReleaseOrderModal
opened={Boolean(releaseItem)}
onClose={closeTruckArrival}
item={releaseItem}
truckPrefill={releaseTruckPrefill}
/>
<EdrTruckExitPapersModal
opened={exitPapersOpen}
onClose={() => setExitPapersOpen(false)}
record={exitPapersRecord}
/>
<TruckDetentionModal
opened={Boolean(detentionRecord)}
onClose={() => setDetentionRecord(null)}
record={detentionRecord}
/>
<ProofOfDeliveryModal
opened={Boolean(podRecord)}
onClose={() => setPodRecord(null)}
lastMileId={podRecord?.id ?? null}
reference={podRecord?.booking?.reference ?? null}
onDone={() => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: ["vehicles"] });
}}
/>
</Stack>
);
};
export default LastMilePage;