mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
2348 lines
94 KiB
TypeScript
2348 lines
94 KiB
TypeScript
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
|
||
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;
|