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 = { 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 = { 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> = { 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 }) => ( {label} {value} ); const BookingInfo = ({ record }: { record: FirstMileRecord }) => { const hasPickupAddress = record.booking?.firstMilePickupAddress != null; return ( {bookingRef(record)} {STATUS_META[record.status].label} {isAssigned(record) ? "Assigned" : "Unassigned"} {hasPickupAddress && } ); }; type TripSlipVehicle = NonNullable[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 = () => ( EDR FREIGHT APPROVED OPERATIONS ); const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => ( {title} Name: Signature: {stamp && ( {stamp} )} ); const TripSlipDocument = ({ record, vehicle, }: { record: FirstMileRecord; vehicle?: TripSlipVehicle | null; }) => ( EDR Freight First Mile Trip Slip {bookingRef(record)} {requestedDate(record)} {tripSlipRows(record, vehicle).map(([label, value]) => ( ))} } /> ); const escapeHtml = (v: string) => v.replace(/&/g, "&").replace(//g, ">"); const buildTripSlipHtml = (record: FirstMileRecord, vehicle?: TripSlipVehicle | null) => { const rows = tripSlipRows(record, vehicle) .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); const sig = (title: string, withStamp: boolean) => `
${title}
Name:
Signature:
${withStamp ? '
EDR FREIGHTAPPROVEDOPERATIONS
' : ""}
`; return ` Trip Slip ${escapeHtml(bookingRef(record))}

EDR Freight

First Mile Trip Slip

${escapeHtml(bookingRef(record))}${escapeHtml(requestedDate(record))}
${rows}
Acknowledgement
${sig("Driver", false)}${sig("Operator", true)}
`; }; 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("ALL"); const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [assignOpen, setAssignOpen] = useState(false); const [bulkMode, setBulkMode] = useState(false); const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); // Which vehicle the trip slip is for (per-truck), + the pre-print picker. const [tripSlipVehicleId, setTripSlipVehicleId] = useState(null); const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(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(null); const [acceptVehicleValue, setAcceptVehicleValue] = useState(null); const [bookingSearch, setBookingSearch] = useState(""); const [distanceOpen, setDistanceOpen] = useState(false); // Per-vehicle actual distance, keyed by vehicleId. const [distanceRows, setDistanceRows] = useState>({}); const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false); const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState(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 = {}; 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 = { 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(); 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[] => { const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; return [ { id: "select", size: 40, meta: { headerClassName, cellClassName }, header: ({ table }) => ( table.toggleAllPageRowsSelected(e.currentTarget.checked)} /> ), cell: ({ row }) => ( ), }, { id: "bookingRef", header: "Booking", meta: { headerClassName, cellClassName }, cell: ({ row }) => {bookingRef(row.original)}, }, { 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 ( {assigns.map(labelFor).join("\n")} } > {assigns[0].vehicle ? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ") : assigns[0].vehicleId} +{assigns.length - 1} ); } return vehicleLabel(row.original) ?? Unassigned; }, }, { id: "exactKm", header: "Actual Distance (KM)", meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : , }, { 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 ; } const status = String((row.original as any).paid ? "PAID" : invoice.status || "").toUpperCase(); const badge = INVOICE_STATUS_META[status] ?? { color: "gray", label: status || "—" }; return ( 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" }} > {shortInvoiceNo(invoice.number)} {status && {badge.label}} ); }, }, { id: "status", header: "Status", meta: { headerClassName, cellClassName }, cell: ({ row }) => { const meta = STATUS_META[row.original.status]; return {meta.label}; }, }, { 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 ( } 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} } disabled={assigned} onClick={() => openAssign(row.original.id)} > Assign } disabled={!assigned || Boolean(row.original.invoice)} onClick={() => openAssign(row.original.id)} > Reassign } onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }} > View detail } disabled={!canReceiveToWarehouse} onClick={() => openWarehouseReceive(row.original)} > Receive to warehouse } disabled={Boolean(row.original.invoice)} onClick={() => openDistance(row.original.id)} > Add distance } 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"} {canPrint && ( } onClick={() => handlePrintTripSlip(row.original)} > Print trip slip )} {!isPaid && ( <> } color="red" disabled={Boolean(row.original.invoice)} onClick={() => { if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) { deleteMutation.mutate(row.original.id); } }} > Delete )} ); }, }, ]; // eslint-disable-next-line react-hooks/exhaustive-deps }, [vehicleOptions]); return ( setSearch(e.currentTarget.value)} w={260} /> {selectedIds.length > 0 && ( )} {FILTER_OPTIONS.map((option) => { const active = statusFilter === option.value; return ( ); })} row.id, state: { pagination, rowSelection }, onPaginationChange: setPagination, onRowSelectionChange: setRowSelection, }} containerClassName="border-0 shadow-none bg-transparent" footer={({ table, pagination: fp }) => ( )} /> {/* Assign / Reassign modal */} Assign Vehicle} size="lg" radius="lg" centered > {bulkMode ? ( Assigning vehicles to{" "} {selectedIds.length}{" "} selected {selectedIds.length === 1 ? "pickup" : "pickups"}. ) : activeRecord ? ( ) : ( No unassigned pickups available. )} {vehicleRows.map((row, i) => ( )} {/* Add Actual Distance modal */} Add Actual Distance} size="md" radius="lg" centered > {activeRecord && ( {bookingRef(activeRecord)} Est. {activeRecord.estimatedKm ?? "—"} km )} {(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? ( Assign a vehicle before entering distance. ) : ( {activeRecord!.vehicleAssignments!.map((a) => { const v = a.vehicle; const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; return ( setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") })) } min={0} step={0.1} decimalScale={2} /> ); })} Total {Object.values(distanceRows) .reduce((s, val) => s + (parseFloat(val) || 0), 0) .toFixed(2)}{" "} km )} ); }; export default FirstMilePage;