mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
1714 lines
66 KiB
TypeScript
1714 lines
66 KiB
TypeScript
import { type ReactNode, useMemo, useState } from "react";
|
|
import {
|
|
ArrowRight,
|
|
ChevronRight,
|
|
Eye,
|
|
MoreHorizontal,
|
|
PackageCheck,
|
|
Plus,
|
|
Printer,
|
|
Receipt,
|
|
RefreshCw,
|
|
Ruler,
|
|
Trash,
|
|
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,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Checkbox,
|
|
Divider,
|
|
Group,
|
|
Menu,
|
|
Modal,
|
|
NumberInput,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Tooltip,
|
|
UnstyledButton,
|
|
} from "@mantine/core";
|
|
|
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
|
import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal";
|
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import {
|
|
FIRST_MILE_STATUSES,
|
|
type FirstMileApiStatus,
|
|
type FirstMileRecord,
|
|
type FirstMileVehicle,
|
|
firstMileService,
|
|
} from "@/services/first-mile.service";
|
|
import { bookingsService } from "@/services/bookings.service";
|
|
import { vehiclesService } from "@/services/vehicles.service";
|
|
import { ratesService } from "@/services/rates.service";
|
|
import type { BookingDetail } from "@/types/booking";
|
|
|
|
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<FirstMileApiStatus, { 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" },
|
|
RECEIVED_TO_PORT: { label: "Received to Port", 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" },
|
|
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<FirstMileApiStatus, FirstMileApiStatus>> = {
|
|
PAYMENT_PENDING: "READY_TO_TRANSIT",
|
|
READY_TO_TRANSIT: "IN_TRANSIT",
|
|
IN_TRANSIT: "RECEIVED_TO_PORT",
|
|
};
|
|
|
|
type AssignmentStatus = "ASSIGNED" | "UNASSIGNED";
|
|
type StatusFilter = "ALL" | FirstMileApiStatus | AssignmentStatus;
|
|
|
|
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
|
{ value: "ALL", label: "All" },
|
|
...FIRST_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: FirstMileRecord) => {
|
|
if (!record.vehicle) return null;
|
|
const v = record.vehicle;
|
|
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
|
|
if (v.code) parts.unshift(v.code);
|
|
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
|
|
if (plates) parts.push(plates);
|
|
return parts.join(" · ");
|
|
};
|
|
|
|
const isAssigned = (record: FirstMileRecord) =>
|
|
Boolean(record.vehicleId) || Boolean(record.vehicleAssignments?.length);
|
|
|
|
/** 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: FirstMileRecord): 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;
|
|
};
|
|
|
|
/** Progress stages of the first-mile pickup workflow, for the detail stepper. */
|
|
const computeFirstMileSteps = (record: FirstMileRecord): LastMileStepState[] => {
|
|
const exactKm = record.exactKm;
|
|
const inTransitOrPast =
|
|
record.status === "IN_TRANSIT" || record.status === "RECEIVED_TO_PORT";
|
|
const flags = [
|
|
record.status !== "PAYMENT_PENDING", // Ready to Transit
|
|
isAssigned(record), // Assign vehicle
|
|
inTransitOrPast, // In transit
|
|
exactKm != null, // Add distance
|
|
Boolean(record.invoice), // Generate Invoice
|
|
record.status === "RECEIVED_TO_PORT",// Received to port
|
|
];
|
|
// Current step = earliest incomplete one.
|
|
const activeIdx = flags.findIndex((f) => !f);
|
|
const labels = [
|
|
"Ready to Transit",
|
|
"Assign vehicle",
|
|
"In transit",
|
|
"Add distance",
|
|
"Generate Invoice",
|
|
"Received to port",
|
|
];
|
|
const vehCount = record.vehicleAssignments?.length ?? (record.vehicleId ? 1 : 0);
|
|
const primaryPlate =
|
|
record.vehicle?.plateNumber ??
|
|
record.vehicleAssignments?.[0]?.vehicle?.plateNumber ??
|
|
null;
|
|
const details: (string | null)[] = [
|
|
null,
|
|
vehCount > 1 ? `${vehCount} vehicles` : primaryPlate,
|
|
null,
|
|
exactKm != null ? `${exactKm} KM` : null,
|
|
record.invoice?.number ?? null,
|
|
null,
|
|
];
|
|
return labels.map((label, i) => ({
|
|
label,
|
|
done: flags[i],
|
|
active: i === activeIdx,
|
|
detail: details[i],
|
|
}));
|
|
};
|
|
|
|
// Paid = record flag set OR its invoice reached PAID.
|
|
const isPaidRecord = (r: FirstMileRecord) =>
|
|
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: FirstMileRecord) =>
|
|
Number(r.remainingPayment) > 0 && !isPaidRecord(r);
|
|
|
|
// Map API record → display fields used in modals and trip slip
|
|
const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId;
|
|
const currencyOf = (r: FirstMileRecord) => r.booking?.paymentCurrency ?? "ETB";
|
|
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
|
|
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
|
|
const cargoDesc = (r: FirstMileRecord) => {
|
|
const parts = [r.booking?.cargoType?.label ?? r.booking?.cargoFreeText].filter(Boolean);
|
|
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
|
return parts.join(" · ") || "—";
|
|
};
|
|
// First-mile destination is the origin yard (pickup → origin yard)
|
|
const destinationYardName = (r: FirstMileRecord) =>
|
|
r.booking?.originYard?.label ?? "—";
|
|
const contactPersonName = (r: FirstMileRecord) =>
|
|
r.booking?.company?.contactPersonName ?? "—";
|
|
const contactPhone = (r: FirstMileRecord) =>
|
|
r.booking?.company?.contactPersonPhone ?? r.booking?.company?.phone ?? "—";
|
|
const requestedDate = (r: FirstMileRecord) => {
|
|
const d = r.booking?.scheduledDate;
|
|
return d ? new Date(d).toISOString().slice(0, 10) : "—";
|
|
};
|
|
const serviceTypeName = (r: FirstMileRecord) =>
|
|
r.booking?.serviceType?.label ?? "—";
|
|
|
|
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: FirstMileRecord }) => {
|
|
const hasPickupAddress = record.booking?.firstMilePickupAddress != 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)} />
|
|
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
|
|
<InfoRow label="Destination (origin yard)" value={destinationYardName(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<FirstMileRecord["vehicleAssignments"]>[number];
|
|
|
|
const tripSlipRows = (
|
|
record: FirstMileRecord,
|
|
vehicle?: TripSlipVehicle | null,
|
|
): [string, string][] => {
|
|
// Per-vehicle block when a specific truck is chosen (its own driver, container
|
|
// 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 location", pickupLocation(record)],
|
|
["Destination yard", destinationYardName(record)],
|
|
["Cargo", cargoDesc(record)],
|
|
["Advanced Payment", formatPrice(record.advancedPayment, currencyOf(record))],
|
|
["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))],
|
|
...vehicleRows,
|
|
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
|
|
["Requested date", requestedDate(record)],
|
|
["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"],
|
|
["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: FirstMileRecord;
|
|
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}>First 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: FirstMileRecord, 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 onload="window.print()">
|
|
<div class="head"><h1>EDR Freight</h1><p>First 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 FirstMilePage = () => {
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
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<FirstMileRecord | 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);
|
|
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
|
const [vehicleRows, setVehicleRows] = useState<
|
|
Array<{ vehicleId: string | null; containerNumber: string }>
|
|
>([{ vehicleId: null, containerNumber: "" }]);
|
|
|
|
const [acceptOpen, setAcceptOpen] = useState(false);
|
|
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
|
|
const [selectedBooking, setSelectedBooking] = useState<BookingDetail | null>(null);
|
|
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
|
|
const [bookingSearch, setBookingSearch] = useState("");
|
|
|
|
const [distanceOpen, setDistanceOpen] = useState(false);
|
|
// Per-vehicle actual distance, keyed by vehicleId.
|
|
const [distanceRows, setDistanceRows] = useState<Record<string, string>>({});
|
|
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
|
|
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
|
|
|
|
|
|
const { data: listData, isLoading } = useQuery({
|
|
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
|
queryFn: async () => {
|
|
const res = await firstMileService.list();
|
|
return res.data;
|
|
},
|
|
});
|
|
|
|
const { data: vehiclesData } = useQuery({
|
|
queryKey: ["vehicles", "free"],
|
|
queryFn: async () => {
|
|
const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" });
|
|
return res.data;
|
|
},
|
|
});
|
|
|
|
const { data: ratesData } = useQuery({
|
|
queryKey: ["rates", "FIRST_MILE"],
|
|
queryFn: async () => {
|
|
const res = await ratesService.getByType("FIRST_MILE");
|
|
return res.data;
|
|
},
|
|
});
|
|
|
|
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
|
|
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
|
|
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
|
|
enabled: acceptOpen,
|
|
});
|
|
const paidBookings = paidBookingsData?.items ?? [];
|
|
|
|
const records = listData?.data ?? [];
|
|
const existingFirstMileBookingIds = useMemo(
|
|
() => new Set(records.map((record) => record.bookingId)),
|
|
[records],
|
|
);
|
|
|
|
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?: FirstMileApiStatus; vehicleId?: string | null } }) =>
|
|
firstMileService.update(id, data),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
|
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 }>;
|
|
}) => firstMileService.setVehicles(id, vehicles),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
|
void qc.invalidateQueries({ queryKey: ["vehicles"] });
|
|
},
|
|
onError: () => {
|
|
toast({ title: "Assign failed", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const setDistancesMutation = useMutation({
|
|
mutationFn: ({
|
|
id,
|
|
distances,
|
|
remainingPayment,
|
|
}: {
|
|
id: string;
|
|
distances: Array<{ vehicleId: string; distanceKm: number }>;
|
|
remainingPayment?: number;
|
|
}) => firstMileService.setDistances(id, distances, remainingPayment),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
|
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
|
|
closeDistance();
|
|
},
|
|
onError: () => {
|
|
toast({ title: "Update failed", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => firstMileService.remove(id),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
|
toast({ title: "Record deleted", description: "First-mile record removed successfully." });
|
|
},
|
|
onError: () => {
|
|
toast({ title: "Delete failed", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const generateInvoiceMutation = useMutation({
|
|
mutationFn: (id: string) => firstMileService.generateInvoice(id),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
|
toast({ title: "Invoice generated" });
|
|
},
|
|
onError: () => {
|
|
toast({ title: "Invoice generation failed", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const acceptMutation = useMutation({
|
|
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
|
|
const res = await firstMileService.accept(reference);
|
|
const created = res.data;
|
|
if (!created?.id) {
|
|
throw new Error("First-mile leg was not created for this booking.");
|
|
}
|
|
if (vehicleId) await firstMileService.update(created.id, { vehicleId });
|
|
return created;
|
|
},
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
|
void qc.invalidateQueries({ queryKey: ["vehicles"] });
|
|
toast({ title: "Booking accepted", description: "First-mile leg created successfully." });
|
|
closeAccept();
|
|
},
|
|
onError: (err: unknown) => {
|
|
const description =
|
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
(err instanceof Error ? err.message : undefined);
|
|
toast({ title: "Accept failed", description, variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const activeRecord = useMemo(
|
|
() => records.find((r) => r.id === activeId) ?? null,
|
|
[records, activeId],
|
|
);
|
|
|
|
// Picker options = 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?: FirstMileVehicle | 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);
|
|
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]);
|
|
|
|
const selectedIds = useMemo(
|
|
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
|
[rowSelection],
|
|
);
|
|
|
|
const firstMileEligiblePaidBookings = useMemo(
|
|
() =>
|
|
paidBookings.filter(
|
|
(booking) => {
|
|
if (existingFirstMileBookingIds.has(booking.id)) return false;
|
|
if (booking.paymentStatus !== "PAID") return false;
|
|
if (booking.tradeDirection !== "EXPORT") return false;
|
|
const hasPickupAddress = booking.firstMilePickupAddress?.trim() ?? false;
|
|
const includesFirstMile = booking.serviceType?.includesFirstMile ?? false;
|
|
return hasPickupAddress || includesFirstMile;
|
|
},
|
|
),
|
|
[existingFirstMileBookingIds, paidBookings],
|
|
);
|
|
|
|
const filteredPaidBookings = useMemo(() => {
|
|
const term = bookingSearch.trim().toLowerCase();
|
|
if (!term) return firstMileEligiblePaidBookings;
|
|
return firstMileEligiblePaidBookings.filter((b) =>
|
|
[b.reference, b.company?.name, b.company?.companyName]
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.includes(term),
|
|
);
|
|
}, [firstMileEligiblePaidBookings, bookingSearch]);
|
|
|
|
const openAccept = () => {
|
|
setAcceptOpen(true);
|
|
setAcceptStep(1);
|
|
setSelectedBooking(null);
|
|
setAcceptVehicleValue(null);
|
|
setBookingSearch("");
|
|
};
|
|
|
|
const closeAccept = () => {
|
|
setAcceptOpen(false);
|
|
setAcceptStep(1);
|
|
setSelectedBooking(null);
|
|
setAcceptVehicleValue(null);
|
|
setBookingSearch("");
|
|
};
|
|
|
|
const handleAcceptConfirm = () => {
|
|
if (!selectedBooking) return;
|
|
acceptMutation.mutate({ reference: selectedBooking.reference, vehicleId: acceptVehicleValue });
|
|
};
|
|
|
|
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 openWarehouseReceive = (record: FirstMileRecord) => {
|
|
setWarehouseReceiveRecord(record);
|
|
setWarehouseReceiveOpen(true);
|
|
};
|
|
|
|
const closeWarehouseReceive = () => {
|
|
setWarehouseReceiveOpen(false);
|
|
setWarehouseReceiveRecord(null);
|
|
};
|
|
|
|
const handleSaveDistance = () => {
|
|
const distances = Object.entries(distanceRows)
|
|
.map(([vehicleId, val]) => ({ vehicleId, distanceKm: parseFloat(val) }))
|
|
.filter((d) => !Number.isNaN(d.distanceKm) && d.distanceKm >= 0);
|
|
|
|
if (!activeId || !distances.length) {
|
|
toast({ title: "Invalid distance", description: "Enter a distance for at least one vehicle.", variant: "destructive" });
|
|
return;
|
|
}
|
|
|
|
const total = distances.reduce((s, d) => s + d.distanceKm, 0);
|
|
let remainingPayment: number | undefined;
|
|
if (ratesData?.data) {
|
|
const firstMileRate = ratesData.data.find(
|
|
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
|
|
);
|
|
if (firstMileRate) {
|
|
remainingPayment = total * parseFloat(firstMileRate.rateValue);
|
|
}
|
|
}
|
|
|
|
setDistancesMutation.mutate({ id: activeId, distances, remainingPayment });
|
|
};
|
|
|
|
const matchesFilter = (r: FirstMileRecord) => {
|
|
if (filterPostPaymentPending && !isPostPaymentPending(r)) return false;
|
|
switch (statusFilter) {
|
|
case "ALL": return true;
|
|
case "ASSIGNED": return isAssigned(r);
|
|
case "UNASSIGNED": return !isAssigned(r);
|
|
default: return r.status === statusFilter;
|
|
}
|
|
};
|
|
|
|
const statusCounts = useMemo(() => {
|
|
const counts: Record<StatusFilter, number> = {
|
|
ALL: records.length,
|
|
PAYMENT_PENDING: 0,
|
|
READY_TO_TRANSIT: 0,
|
|
IN_TRANSIT: 0,
|
|
RECEIVED_TO_PORT: 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],
|
|
);
|
|
|
|
const filteredRecords = useMemo(() => {
|
|
const term = search.trim().toLowerCase();
|
|
return records.filter((r) => {
|
|
if (!matchesFilter(r)) return false;
|
|
if (!term) return true;
|
|
return [bookingRef(r), customerName(r), pickupLocation(r), cargoDesc(r)]
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.includes(term);
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [records, search, statusFilter, filterPostPaymentPending]);
|
|
|
|
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
|
|
const pagedRecords = useMemo(() => {
|
|
const start = pagination.pageIndex * pagination.pageSize;
|
|
return filteredRecords.slice(start, start + pagination.pageSize);
|
|
}, [filteredRecords, pagination]);
|
|
|
|
const openAssign = (id: string | null) => {
|
|
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
|
const rec = records.find((r) => r.id === resolved);
|
|
// Prefill each row's container number from the booking's container numbers
|
|
// (by order) when the assignment doesn't already carry one.
|
|
const nums = rec ? bookingContainerNumbers(rec) : [];
|
|
const rows =
|
|
rec?.vehicleAssignments?.length
|
|
? rec.vehicleAssignments.map((a, i) => ({
|
|
vehicleId: a.vehicleId,
|
|
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
|
}))
|
|
: rec?.vehicleId
|
|
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
|
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
|
setBulkMode(false);
|
|
setActiveId(resolved);
|
|
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
|
setAssignOpen(true);
|
|
};
|
|
|
|
const openBulkAssign = () => {
|
|
setBulkMode(true);
|
|
setActiveId(null);
|
|
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
|
setAssignOpen(true);
|
|
};
|
|
|
|
const closeAssign = () => {
|
|
setAssignOpen(false);
|
|
setBulkMode(false);
|
|
setActiveId(null);
|
|
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
|
};
|
|
|
|
const handleAssign = () => {
|
|
const seen = new Set<string>();
|
|
const vehicles = vehicleRows
|
|
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
|
|
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
|
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
|
|
const count = vehicles.length;
|
|
const targetIds = bulkMode
|
|
? selectedIds
|
|
: [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id));
|
|
|
|
if (!targetIds.length) return;
|
|
|
|
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
|
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
|
.then(() => {
|
|
toast({
|
|
title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned",
|
|
description:
|
|
count === 0
|
|
? bulkMode ? `${targetIds.length} pickups` : undefined
|
|
: `${bulkMode ? `${targetIds.length} pickups · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`,
|
|
});
|
|
if (bulkMode) setRowSelection({});
|
|
closeAssign();
|
|
})
|
|
.catch(() => void 0);
|
|
};
|
|
|
|
const handleAdvanceStatus = (record: FirstMileRecord) => {
|
|
const next = NEXT_STATUS[record.status];
|
|
if (!next) return;
|
|
updateMutation.mutate(
|
|
{ id: record.id, data: { status: next } },
|
|
{
|
|
onSuccess: () =>
|
|
toast({ title: "Status updated", description: `${bookingRef(record)} → ${STATUS_META[next].label}` }),
|
|
},
|
|
);
|
|
};
|
|
|
|
const handlePrintTripSlip = (record: FirstMileRecord) => {
|
|
// 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 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<FirstMileRecord>[] => {
|
|
const headerClassName = ruleEngineTable.headerCell;
|
|
const cellClassName = ruleEngineTable.bodyCell;
|
|
return [
|
|
{
|
|
id: "select",
|
|
size: 40,
|
|
meta: { headerClassName, cellClassName },
|
|
header: ({ table }) => (
|
|
<Checkbox
|
|
aria-label="Select all"
|
|
checked={table.getIsAllPageRowsSelected()}
|
|
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
|
|
onChange={(e) => table.toggleAllPageRowsSelected(e.currentTarget.checked)}
|
|
/>
|
|
),
|
|
cell: ({ row }) => (
|
|
<Checkbox
|
|
aria-label="Select row"
|
|
checked={row.getIsSelected()}
|
|
disabled={!row.getCanSelect()}
|
|
onChange={row.getToggleSelectedHandler()}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
id: "bookingRef",
|
|
header: "Booking",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => <Text size="sm" fw={600}>{bookingRef(row.original)}</Text>,
|
|
},
|
|
{
|
|
id: "customer",
|
|
header: "Customer",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => customerName(row.original),
|
|
},
|
|
{
|
|
id: "postPayment",
|
|
header: "Post Payment",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => formatPrice(row.original.remainingPayment, 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 : <Text c="dimmed">—</Text>,
|
|
},
|
|
{
|
|
id: "invoice",
|
|
header: "Invoice",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => {
|
|
// Only show once actually generated — not merely on distance.
|
|
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 canReceiveToWarehouse = row.original.status === "RECEIVED_TO_PORT";
|
|
return (
|
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
|
<Menu
|
|
position="bottom-end"
|
|
width={200}
|
|
withinPortal
|
|
styles={{ dropdown: { maxHeight: 320, overflowY: "auto" } }}
|
|
>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" color="gray" aria-label="Pickup actions">
|
|
<MoreHorizontal size={16} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
leftSection={<ArrowRight size={15} />}
|
|
disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)}
|
|
onClick={() => handleAdvanceStatus(row.original)}
|
|
>
|
|
{nextStatus === "IN_TRANSIT" && !assigned
|
|
? "Assign a vehicle first"
|
|
: nextStatus
|
|
? `Mark ${STATUS_META[nextStatus].label}`
|
|
: STATUS_META[row.original.status].label}
|
|
</Menu.Item>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<Truck size={15} />}
|
|
disabled={assigned}
|
|
onClick={() => openAssign(row.original.id)}
|
|
>
|
|
Assign
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<RefreshCw size={15} />}
|
|
disabled={!assigned || Boolean(row.original.invoice)}
|
|
onClick={() => openAssign(row.original.id)}
|
|
>
|
|
Reassign
|
|
</Menu.Item>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<Eye size={15} />}
|
|
onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }}
|
|
>
|
|
View detail
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<PackageCheck size={15} />}
|
|
disabled={!canReceiveToWarehouse}
|
|
onClick={() => openWarehouseReceive(row.original)}
|
|
>
|
|
Receive to warehouse
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<Ruler size={15} />}
|
|
disabled={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={() => generateInvoiceMutation.mutate(row.original.id)}
|
|
>
|
|
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
|
|
</Menu.Item>
|
|
{canPrint && (
|
|
<Menu.Item
|
|
leftSection={<Printer size={15} />}
|
|
onClick={() => handlePrintTripSlip(row.original)}
|
|
>
|
|
Print trip slip
|
|
</Menu.Item>
|
|
)}
|
|
{!isPaid && (
|
|
<>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<Trash size={15} />}
|
|
color="red"
|
|
disabled={Boolean(row.original.invoice)}
|
|
onClick={() => {
|
|
if (confirm(`Delete first-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]);
|
|
|
|
return (
|
|
<Stack gap="md" p="md">
|
|
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
|
<Stack gap={0}>
|
|
<Box px="md" pt="md" pb="sm" w="100%">
|
|
<Stack gap="sm">
|
|
<Group justify="space-between" wrap="wrap">
|
|
<TextInput
|
|
placeholder="Search pickups…"
|
|
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 first-mile pickups 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: "pickups" } }} />
|
|
)}
|
|
/>
|
|
</Stack>
|
|
</Card>
|
|
|
|
{/* 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 vehicles to{" "}
|
|
<Text span fw={600}>{selectedIds.length}</Text>{" "}
|
|
selected {selectedIds.length === 1 ? "pickup" : "pickups"}.
|
|
</Text>
|
|
) : activeRecord ? (
|
|
<BookingInfo record={activeRecord} />
|
|
) : (
|
|
<Text size="sm" c="dimmed">No unassigned pickups available.</Text>
|
|
)}
|
|
<Divider />
|
|
<Stack gap="xs">
|
|
{vehicleRows.map((row, i) => (
|
|
<Group key={i} gap="xs" wrap="nowrap" align="flex-end">
|
|
<Select
|
|
style={{ flex: 1.4 }}
|
|
label={i === 0 ? "Vehicle" : undefined}
|
|
placeholder={assignVehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
|
|
data={assignVehicleOptions.filter(
|
|
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
|
)}
|
|
value={row.vehicleId}
|
|
onChange={(v) =>
|
|
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
|
}
|
|
searchable
|
|
clearable
|
|
disabled={assignVehicleOptions.length === 0}
|
|
/>
|
|
<TextInput
|
|
style={{ flex: 1 }}
|
|
label={i === 0 ? "Container no." : undefined}
|
|
placeholder="Container number"
|
|
value={row.containerNumber}
|
|
onChange={(e) => {
|
|
const value = e.currentTarget.value;
|
|
setVehicleRows((prev) =>
|
|
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)),
|
|
);
|
|
}}
|
|
/>
|
|
{vehicleRows.length > 1 && (
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
aria-label="Remove vehicle"
|
|
onClick={() => setVehicleRows((prev) => prev.filter((_, idx) => idx !== i))}
|
|
>
|
|
<X size={16} />
|
|
</ActionIcon>
|
|
)}
|
|
</Group>
|
|
))}
|
|
<Button
|
|
variant="light"
|
|
size="xs"
|
|
leftSection={<Plus size={14} />}
|
|
onClick={() =>
|
|
setVehicleRows((prev) => [
|
|
...prev,
|
|
{
|
|
vehicleId: null,
|
|
containerNumber:
|
|
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
|
},
|
|
])
|
|
}
|
|
disabled={
|
|
assignVehicleOptions.length === 0 ||
|
|
vehicleRows.some((r) => !r.vehicleId) ||
|
|
vehicleRows.filter((r) => r.vehicleId).length >= assignVehicleOptions.length
|
|
}
|
|
style={{ alignSelf: "flex-start" }}
|
|
>
|
|
Add vehicle
|
|
</Button>
|
|
</Stack>
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
|
<Button
|
|
onClick={handleAssign}
|
|
loading={setVehiclesMutation.isPending}
|
|
disabled={bulkMode ? selectedIds.length === 0 : !activeRecord}
|
|
>
|
|
{!bulkMode && activeRecord && isAssigned(activeRecord) ? "Update vehicles" : "Assign"}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
{/* View detail modal */}
|
|
<Modal
|
|
opened={detailOpen}
|
|
onClose={() => { setDetailOpen(false); setActiveId(null); }}
|
|
title={<Text fw={600}>Pickup 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">Pickup steps</Text>
|
|
<LastMileStepper steps={computeFirstMileSteps(activeRecord)} />
|
|
</Card>
|
|
)}
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</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>
|
|
|
|
{/* 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>
|
|
|
|
<ReceiveInventoryModal
|
|
opened={warehouseReceiveOpen}
|
|
onClose={closeWarehouseReceive}
|
|
mode="bulk"
|
|
direction="EXPORT"
|
|
bookingId={warehouseReceiveRecord?.bookingId}
|
|
bookingLabel={warehouseReceiveRecord ? bookingRef(warehouseReceiveRecord) : undefined}
|
|
onReceived={() => {
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
|
closeWarehouseReceive();
|
|
}}
|
|
/>
|
|
|
|
{/* Accept Booking modal — step 1: booking list, step 2: details + vehicle */}
|
|
<Modal
|
|
opened={acceptOpen}
|
|
onClose={closeAccept}
|
|
title={
|
|
<Text fw={600}>
|
|
{acceptStep === 1 ? "Select a Paid Booking" : "Accept Booking"}
|
|
</Text>
|
|
}
|
|
size="xl"
|
|
radius="lg"
|
|
centered
|
|
>
|
|
{acceptStep === 1 ? (
|
|
<Stack gap="md">
|
|
<TextInput
|
|
placeholder="Search by reference or company…"
|
|
value={bookingSearch}
|
|
onChange={(e) => setBookingSearch(e.currentTarget.value)}
|
|
/>
|
|
<Stack gap="xs" style={{ maxHeight: 420, overflowY: "auto" }}>
|
|
{bookingsLoading ? (
|
|
<Text c="dimmed" size="sm" ta="center" py="md">Loading bookings…</Text>
|
|
) : filteredPaidBookings.length === 0 ? (
|
|
<Text c="dimmed" size="sm" ta="center" py="md">No paid export bookings need first mile.</Text>
|
|
) : (
|
|
filteredPaidBookings.map((b) => (
|
|
<UnstyledButton
|
|
key={b.id}
|
|
w="100%"
|
|
onClick={() => {
|
|
setSelectedBooking(b);
|
|
setAcceptStep(2);
|
|
}}
|
|
style={{
|
|
borderRadius: "var(--mantine-radius-md)",
|
|
border: "1px solid var(--mantine-color-gray-3)",
|
|
padding: "10px 12px",
|
|
backgroundColor: "var(--mantine-color-white)",
|
|
transition: "background-color 120ms ease, border-color 120ms ease",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
|
|
"var(--mantine-color-blue-0)";
|
|
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
|
"var(--mantine-color-blue-4)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
|
|
"var(--mantine-color-white)";
|
|
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
|
"var(--mantine-color-gray-3)";
|
|
}}
|
|
>
|
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
|
<Stack gap={3} style={{ flex: 1, minWidth: 0 }}>
|
|
<Text fw={700} size="sm" c="dark">{b.reference}</Text>
|
|
<Text size="xs" c="dimmed" truncate>
|
|
{b.company?.name ?? b.company?.companyName ?? "—"}
|
|
</Text>
|
|
</Stack>
|
|
<Stack gap={3} align="flex-end" style={{ flexShrink: 0 }}>
|
|
<Text size="xs" c="dimmed">
|
|
{b.originYard?.label ?? "—"} → {b.destinationYard?.label ?? "—"}
|
|
</Text>
|
|
<Text size="sm" fw={600} c="blue">
|
|
{formatPrice(b.totalAmount)}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{b.scheduledDate ? b.scheduledDate.slice(0, 10) : "—"}
|
|
</Text>
|
|
</Stack>
|
|
<ChevronRight size={16} color="var(--mantine-color-gray-5)" />
|
|
</Group>
|
|
</UnstyledButton>
|
|
))
|
|
)}
|
|
</Stack>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={closeAccept}>Cancel</Button>
|
|
</Group>
|
|
</Stack>
|
|
) : (
|
|
<Stack gap="md">
|
|
{selectedBooking && (
|
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
|
<Stack gap="sm">
|
|
<Group justify="space-between">
|
|
<Text fw={600}>{selectedBooking.reference}</Text>
|
|
<Badge color="green" variant="light" size="sm">PAID</Badge>
|
|
</Group>
|
|
<SimpleGrid cols={2} spacing="sm">
|
|
<InfoRow label="Customer" value={selectedBooking.company?.name ?? selectedBooking.company?.companyName ?? "—"} />
|
|
<InfoRow label="Service type" value={selectedBooking.serviceType?.name ?? "—"} />
|
|
<InfoRow label="Pickup address" value={selectedBooking.firstMilePickupAddress ?? "—"} />
|
|
<InfoRow label="Destination (origin yard)" value={selectedBooking.originYard?.label ?? "—"} />
|
|
<InfoRow label="Train destination yard" value={selectedBooking.destinationYard?.label ?? "—"} />
|
|
<InfoRow label="Cargo type" value={selectedBooking.cargoType?.name ?? "—"} />
|
|
<InfoRow label="Weight (VGM)" value={`${selectedBooking.cargoTotalWeightVgm} t`} />
|
|
<InfoRow label="Total amount" value={formatPrice(selectedBooking.totalAmount)} />
|
|
<InfoRow label="Contact person" value={selectedBooking.company?.contactPersonName ?? "—"} />
|
|
<InfoRow label="Contact phone" value={selectedBooking.company?.contactPersonPhone ?? selectedBooking.company?.phone ?? "—"} />
|
|
<InfoRow label="Requested date" value={selectedBooking.scheduledDate ? selectedBooking.scheduledDate.slice(0, 10) : "—"} />
|
|
</SimpleGrid>
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
<Divider />
|
|
<Select
|
|
label="Assign Vehicle (optional)"
|
|
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
|
|
description={
|
|
vehicleOptions.length === 0
|
|
? "No free vehicles available — you can still accept and assign a vehicle later."
|
|
: undefined
|
|
}
|
|
data={vehicleOptions}
|
|
value={acceptVehicleValue}
|
|
onChange={setAcceptVehicleValue}
|
|
searchable
|
|
clearable
|
|
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={!selectedBooking}
|
|
>
|
|
Accept Booking
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Add Actual Distance modal */}
|
|
<Modal
|
|
opened={distanceOpen}
|
|
onClose={closeDistance}
|
|
title={<Text fw={600}>Add Actual Distance</Text>}
|
|
size="md"
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
{activeRecord && (
|
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
|
<Group justify="space-between">
|
|
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
|
|
<Text size="xs" c="dimmed">Est. {activeRecord.estimatedKm ?? "—"} km</Text>
|
|
</Group>
|
|
</Card>
|
|
)}
|
|
{(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? (
|
|
<Text size="sm" c="dimmed">Assign a vehicle before entering distance.</Text>
|
|
) : (
|
|
<Stack gap="sm">
|
|
{activeRecord!.vehicleAssignments!.map((a) => {
|
|
const v = a.vehicle;
|
|
const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
|
|
return (
|
|
<NumberInput
|
|
key={a.id}
|
|
label={`${label}${a.containerNumber ? ` · ${a.containerNumber}` : ""}`}
|
|
placeholder="Distance (km)"
|
|
value={distanceRows[a.vehicleId] ?? ""}
|
|
onChange={(val) =>
|
|
setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") }))
|
|
}
|
|
min={0}
|
|
step={0.1}
|
|
decimalScale={2}
|
|
/>
|
|
);
|
|
})}
|
|
<Group justify="space-between">
|
|
<Text size="xs" c="dimmed">Total</Text>
|
|
<Text size="sm" fw={600}>
|
|
{Object.values(distanceRows)
|
|
.reduce((s, val) => s + (parseFloat(val) || 0), 0)
|
|
.toFixed(2)}{" "}
|
|
km
|
|
</Text>
|
|
</Group>
|
|
</Stack>
|
|
)}
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button variant="default" onClick={closeDistance}>Cancel</Button>
|
|
<Button
|
|
onClick={handleSaveDistance}
|
|
loading={setDistancesMutation.isPending}
|
|
disabled={Object.values(distanceRows).every((v) => !v)}
|
|
>
|
|
Save
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export default FirstMilePage;
|