import { type ReactNode, useMemo, useState } from "react"; import { ArrowRight, ChevronRight, Eye, MoreHorizontal, Printer, RefreshCw, Ruler, Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { ActionIcon, Badge, Box, Button, Card, Checkbox, Divider, Group, Menu, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput, UnstyledButton, Alert, } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; import { FIRST_MILE_STATUSES, type FirstMileApiStatus, type FirstMileRecord, 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 { api } from "@/auth/http"; import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number) => `ETB ${amount.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" }, }; 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); // Map API record → display fields used in modals and trip slip const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId; 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 }) => ( {bookingRef(record)} {STATUS_META[record.status].label} {isAssigned(record) ? "Assigned" : "Unassigned"} ); const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ ["Customer", customerName(record)], ["Service", serviceTypeName(record)], ["Pickup location", pickupLocation(record)], ["Destination yard", destinationYardName(record)], ["Cargo", cargoDesc(record)], ["Advanced Payment", formatPrice(record.advancedPayment)], ["Post Payment", formatPrice(record.remainingPayment)], ["Vehicle", vehicleLabel(record) ?? "Unassigned"], ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], ["Requested date", requestedDate(record)], ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], ["Status", STATUS_META[record.status].label], ]; const SampleStamp = () => ( EDR FREIGHT APPROVED OPERATIONS ); const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => ( {title} Name: Signature: {stamp && ( {stamp} )} ); const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( EDR Freight First Mile Trip Slip {bookingRef(record)} {requestedDate(record)} {tripSlipRows(record).map(([label, value]) => ( ))} } /> ); const escapeHtml = (v: string) => v.replace(/&/g, "&").replace(//g, ">"); const buildTripSlipHtml = (record: FirstMileRecord) => { const rows = tripSlipRows(record) .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 { 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); const [activeId, setActiveId] = useState(null); const [vehicleValue, setVehicleValue] = useState(null); 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); const [distanceValue, setDistanceValue] = useState(""); const [invoiceOpen, setInvoiceOpen] = useState(false); const [invoiceRecord, setInvoiceRecord] = useState(null); const [containerAllocationOpen, setContainerAllocationOpen] = useState(false); const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = 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", "list"], queryFn: async () => { const res = await vehiclesService.getAll({ status: "ACTIVE" }); 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() }); }, onError: () => { toast({ title: "Update failed", variant: "destructive" }); }, }); const updateDistanceMutation = useMutation({ mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); if (activeRecord) { toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); } closeDistance(); }, onError: () => { toast({ title: "Update 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 }); 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 allocateMutation = useMutation({ mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated" }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") }); setContainerAllocationOpen(false); setContainerAllocationFirstMileId(null); }, onError: () => { toast({ title: "Allocation failed", variant: "destructive" }); }, }); const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], ); const selectedIds = useMemo( () => Object.keys(rowSelection).filter((id) => rowSelection[id]), [rowSelection], ); const firstMileEligiblePaidBookings = useMemo( () => paidBookings.filter( (booking) => booking.tradeDirection === "EXPORT" && !existingFirstMileBookingIds.has(booking.id), ), [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) => { setActiveId(id); setDistanceValue(""); setDistanceOpen(true); }; const closeDistance = () => { setDistanceOpen(false); setActiveId(null); setDistanceValue(""); }; const openInvoice = (record: FirstMileRecord) => { setInvoiceRecord(record); setInvoiceOpen(true); }; const closeInvoice = () => { setInvoiceOpen(false); setInvoiceRecord(null); }; const openContainerAllocation = (firstMileId: string) => { setContainerAllocationFirstMileId(firstMileId); setContainerAllocationOpen(true); }; const closeContainerAllocation = () => { setContainerAllocationOpen(false); setContainerAllocationFirstMileId(null); }; const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); return; } 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) { const rateValue = parseFloat(firstMileRate.rateValue); remainingPayment = distance * rateValue; } } updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); }; const matchesFilter = (r: FirstMileRecord) => { if (filterPostPaymentPending && r.isPostPaymentCompleted) 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 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; setBulkMode(false); setActiveId(resolved); setVehicleValue(null); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); setVehicleValue(null); setAssignOpen(true); }; const closeAssign = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); setVehicleValue(null); }; const handleAssign = () => { if (!vehicleValue) { toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); return; } const targetIds = bulkMode ? selectedIds : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); if (!targetIds.length) return; const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) .then(() => { toast({ title: "Vehicle assigned", description: bulkMode ? `${targetIds.length} pickups → ${selectedLabel}` : selectedLabel, }); 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) => { setTripSlipRecord(record); setTripSlipOpen(true); }; const printTripSlip = () => { if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); if (!win) { toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } win.document.write(buildTripSlipHtml(tripSlipRecord)); win.document.close(); }; const columns = useMemo((): ColumnDef[] => { 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: "pickup", header: "Pickup", meta: { headerClassName, cellClassName }, cell: ({ row }) => pickupLocation(row.original), }, { id: "destination", header: "Destination", meta: { headerClassName, cellClassName }, cell: ({ row }) => destinationYardName(row.original), }, { id: "cargo", header: "Cargo", meta: { headerClassName, cellClassName }, cell: ({ row }) => cargoDesc(row.original), }, { id: "advancedPayment", header: "Advanced Payment", meta: { headerClassName, cellClassName }, cell: ({ row }) => formatPrice(row.original.advancedPayment), }, { id: "postPayment", header: "Post Payment", meta: { headerClassName, cellClassName }, cell: ({ row }) => formatPrice(row.original.remainingPayment), }, { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, cell: ({ row }) => vehicleLabel(row.original) ?? , }, { id: "estimatedKm", header: "Est. Distance (KM)", meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : , }, { 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 }) => { const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; if (!hasDistance) { return ; } return ( openInvoice(row.original)} c="blue" fw={500} style={{ textDecoration: "underline", cursor: "pointer" }} > #345 ); }, }, { id: "status", header: "Status", meta: { headerClassName, cellClassName }, cell: ({ row }) => { const meta = STATUS_META[row.original.status]; return {meta.label}; }, }, { id: "assignment", header: "Assignment", meta: { headerClassName, cellClassName }, cell: ({ row }) => ( {isAssigned(row.original) ? "Assigned" : "Unassigned"} ), }, { 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"; return ( } disabled={!nextStatus} onClick={() => handleAdvanceStatus(row.original)} > {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} } disabled={assigned} onClick={() => openAssign(row.original.id)} > Assign } disabled={!assigned} onClick={() => openAssign(row.original.id)} > Reassign } onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }} > View detail } onClick={() => openDistance(row.original.id)} > Add distance {canPrint && ( } onClick={() => handlePrintTripSlip(row.original)} > Print trip slip )} ); }, }, ]; // 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 a vehicle to{" "} {selectedIds.length}{" "} selected {selectedIds.length === 1 ? "pickup" : "pickups"}. ) : activeRecord ? ( ) : ( No unassigned pickups available. )} )} {/* Add Actual Distance modal */} Add Actual Distance} size="md" radius="lg" centered > {activeRecord && ( {bookingRef(activeRecord)} Customer {customerName(activeRecord)} Est. Distance (KM) {activeRecord.estimatedKm ?? "—"} )} setDistanceValue(String(v ?? ""))} min={0} step={0.1} decimalScale={2} /> {/* Invoice modal */} Invoice #345} size="lg" radius="lg" centered > {invoiceRecord && ( <> EDR Freight Invoice #345 Post Payment {formatPrice(invoiceRecord.remainingPayment)} Advanced Payment {formatPrice(invoiceRecord.advancedPayment)} {(() => { const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); const difference = postPayment - advancedPayment; if (difference > 0) { return ( Remaining to Pay {formatPrice(difference)} ); } else if (difference < 0) { return ( Refund {formatPrice(Math.abs(difference))} ); } else { return ( Status Settled ); } })()} )} {/* Container Allocation modal */} Allocate Containers to Vehicles} size="xl" radius="lg" centered > {activeRecord && ( <> {/* Capacity guidance */} {activeRecord.booking?.cargoType?.label === "BULK" ? ( Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows. Capacity: TBD — TODO: add vehicle capacity_tons to vehicle API if missing ) : ( One vehicle per container. Each container will be assigned to a single vehicle. )} {/* Container table */} { await allocateMutation.mutateAsync(allocations); }} /> )} ); }; export default FirstMilePage;