diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6c454052d..fc58e2555 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -52,6 +52,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; @@ -114,6 +115,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.trainScheduling.view, }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, { label: "Last Mile", href: "/dashboard/operations/last-mile", @@ -415,6 +422,14 @@ const App = () => { } /> + + + + } + /> = [ subtitle: "View booking payment transactions", }, }, + { + prefix: "/dashboard/operations/first-mile", + meta: { + title: "First Mile", + subtitle: "Assign vehicles to initial-leg pickups", + }, + }, { prefix: "/dashboard/operations/last-mile", meta: { diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx new file mode 100644 index 000000000..768803896 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -0,0 +1,822 @@ +import { type ReactNode, useMemo, useState } from "react"; +import { + ArrowRight, + Eye, + MoreHorizontal, + Printer, + RefreshCw, + Truck, +} from "lucide-react"; +import type { ColumnDef } from "@edr/ui-common"; +import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; +import { + ActionIcon, + Badge, + Box, + Button, + Card, + Divider, + Group, + Menu, + Modal, + Select, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; + +import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { useToast } from "@/hooks/use-toast"; + +type FirstMileStatus = "UNASSIGNED" | "ASSIGNED"; +type PickupStatus = "PAYMENT_PENDING" | "READY_FOR_PICKUP" | "PICKED_UP"; + +interface FirstMileJob { + id: string; + bookingRef: string; + customer: string; + pickup: string; + cargo: string; + status: FirstMileStatus; + pickupStatus: PickupStatus; + assignedVehicle: string | null; + // Booking info shown in the Assign / View Detail modals. + serviceType: string; + weight: string; + price: number; + destinationYard: string; + contactName: string; + contactPhone: string; + requestedDate: string; +} + +const formatPrice = (amount: number) => + `ETB ${amount.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + +const PICKUP_STATUS_META: Record< + PickupStatus, + { label: string; color: string } +> = { + PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" }, + READY_FOR_PICKUP: { label: "Ready for Pickup", color: "blue" }, + PICKED_UP: { label: "Picked Up", color: "green" }, +}; + +// Forward-only lifecycle: Payment Pending → Ready for Pickup → Picked Up. +const NEXT_PICKUP_STATUS: Partial> = { + PAYMENT_PENDING: "READY_FOR_PICKUP", + READY_FOR_PICKUP: "PICKED_UP", +}; + +// Single filter covering both the pickup lifecycle and assignment state. +type StatusFilter = + | "ALL" + | PickupStatus + | FirstMileStatus; + +const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ + { value: "ALL", label: "All statuses" }, + { value: "PAYMENT_PENDING", label: "Payment Pending" }, + { value: "READY_FOR_PICKUP", label: "Ready for Pickup" }, + { value: "PICKED_UP", label: "Picked Up" }, + { value: "ASSIGNED", label: "Assigned" }, + { value: "UNASSIGNED", label: "Unassigned" }, +]; + +// Placeholder data — replace with a real first-mile service once the API exists. +const PLACEHOLDER_JOBS: FirstMileJob[] = [ + { + id: "1", + bookingRef: "BK-10242", + customer: "Awash Trading PLC", + pickup: "Kera Warehouse, Addis Ababa", + cargo: "20ft container · Electronics", + status: "UNASSIGNED", + pickupStatus: "PAYMENT_PENDING", + assignedVehicle: null, + serviceType: "Door-to-terminal (First Mile)", + weight: "12.4 t", + price: 4200, + destinationYard: "Indode Dry Port", + contactName: "Selam Bekele", + contactPhone: "+251 911 234 567", + requestedDate: "2026-06-22", + }, + { + id: "2", + bookingRef: "BK-10239", + customer: "Dire Logistics", + pickup: "Factory Gate 4, Dire Dawa", + cargo: "Bulk · 18t Cement", + status: "ASSIGNED", + pickupStatus: "READY_FOR_PICKUP", + assignedVehicle: "Isuzu FVR (3-AA-45821)", + serviceType: "Door-to-terminal (First Mile)", + weight: "18.0 t", + price: 3000, + destinationYard: "Dire Dawa Terminal", + contactName: "Yonas Tadesse", + contactPhone: "+251 912 887 010", + requestedDate: "2026-06-21", + }, + { + id: "3", + bookingRef: "BK-10235", + customer: "Horizon Imports", + pickup: "Lebu Industrial Park, Addis Ababa", + cargo: "40ft container · Machinery", + status: "UNASSIGNED", + pickupStatus: "PICKED_UP", + assignedVehicle: null, + serviceType: "Door-to-terminal (First Mile)", + weight: "24.7 t", + price: 6500, + destinationYard: "Mojo Dry Port", + contactName: "Hanna Girma", + contactPhone: "+251 913 445 221", + requestedDate: "2026-06-23", + }, +]; + +// Placeholder vehicle options — replace with the vehicles service. +const VEHICLE_OPTIONS = [ + { value: "isuzu-fvr-45821", label: "Isuzu FVR (3-AA-45821)" }, + { value: "sino-howo-12044", label: "Sinotruk Howo (3-AA-12044)" }, + { value: "mercedes-actros-90113", label: "Mercedes Actros (3-AA-90113)" }, +]; + +const InfoRow = ({ label, value }: { label: string; value: string }) => ( + + + {label} + + {value} + +); + +const BookingInfo = ({ job }: { job: FirstMileJob }) => ( + + + + {job.bookingRef} + + + {PICKUP_STATUS_META[job.pickupStatus].label} + + + {job.status === "ASSIGNED" ? "Assigned" : "Unassigned"} + + + + + + + + + + + + + + + + + + +); + +const tripSlipRows = (job: FirstMileJob): [string, string][] => [ + ["Customer", job.customer], + ["Service", job.serviceType], + ["Pickup location", job.pickup], + ["Destination yard", job.destinationYard], + ["Cargo", job.cargo], + ["Weight", job.weight], + ["Price", formatPrice(job.price)], + ["Vehicle", job.assignedVehicle ?? "Unassigned"], + ["Contact", `${job.contactName} · ${job.contactPhone}`], + ["Requested date", job.requestedDate], + ["Pickup status", PICKUP_STATUS_META[job.pickupStatus].label], +]; + +const SampleStamp = () => ( + + + + + EDR FREIGHT + + + APPROVED + + + OPERATIONS + + + + +); + +const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => ( + + + {title} + + + + Name: + + + + + + Signature: + + + + {stamp && ( + + {stamp} + + )} + +); + +const TripSlipDocument = ({ job }: { job: FirstMileJob }) => ( + + + EDR Freight + + First Mile Trip Slip + + + + + {job.bookingRef} + + + {job.requestedDate} + + + + + {tripSlipRows(job).map(([label, value]) => ( + + ))} + + + + + } /> + + +); + +const escapeHtml = (value: string) => + value + .replace(/&/g, "&") + .replace(//g, ">"); + +const buildTripSlipHtml = (job: FirstMileJob) => { + const rows = tripSlipRows(job) + .map( + ([label, value]) => + `${escapeHtml(label)}${escapeHtml(value)}`, + ) + .join(""); + const signature = (title: string, withStamp: boolean) => ` + + ${title} + Name: + Signature: + ${ + withStamp + ? 'EDR FREIGHTAPPROVEDOPERATIONS' + : "" + } + `; + return ` + Trip Slip ${escapeHtml(job.bookingRef)} + + + EDR FreightFirst Mile Trip Slip + ${escapeHtml(job.bookingRef)}${escapeHtml(job.requestedDate)} + ${rows} + Acknowledgement + ${signature("Driver", false)}${signature("Operator", true)} + `; +}; + +const FirstMilePage = () => { + const { toast } = useToast(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + + const [jobs, setJobs] = useState(PLACEHOLDER_JOBS); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("ALL"); + + const [assignOpen, setAssignOpen] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); + const [tripSlipOpen, setTripSlipOpen] = useState(false); + const [tripSlipJob, setTripSlipJob] = useState(null); + const [activeJobId, setActiveJobId] = useState(null); + const [vehicleValue, setVehicleValue] = useState(null); + + const activeJob = useMemo( + () => jobs.find((job) => job.id === activeJobId) ?? null, + [jobs, activeJobId], + ); + + const matchesStatusFilter = (job: FirstMileJob) => { + switch (statusFilter) { + case "ALL": + return true; + case "ASSIGNED": + case "UNASSIGNED": + return job.status === statusFilter; + default: + return job.pickupStatus === statusFilter; + } + }; + + const statusCounts = useMemo(() => { + const counts: Record = { + ALL: jobs.length, + PAYMENT_PENDING: 0, + READY_FOR_PICKUP: 0, + PICKED_UP: 0, + ASSIGNED: 0, + UNASSIGNED: 0, + }; + for (const job of jobs) { + counts[job.pickupStatus] += 1; + counts[job.status] += 1; + } + return counts; + }, [jobs]); + + const filteredJobs = useMemo(() => { + const term = search.trim().toLowerCase(); + return jobs.filter((job) => { + if (!matchesStatusFilter(job)) return false; + if (!term) return true; + return [job.bookingRef, job.customer, job.pickup, job.cargo] + .join(" ") + .toLowerCase() + .includes(term); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jobs, search, statusFilter]); + + const pageCount = Math.max(1, Math.ceil(filteredJobs.length / pagination.pageSize)); + const pagedJobs = useMemo(() => { + const start = pagination.pageIndex * pagination.pageSize; + return filteredJobs.slice(start, start + pagination.pageSize); + }, [filteredJobs, pagination.pageIndex, pagination.pageSize]); + + const openAssign = (jobId: string | null) => { + const resolved = jobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id ?? null; + setActiveJobId(resolved); + setVehicleValue(null); + setAssignOpen(true); + }; + + const openDetail = (jobId: string) => { + setActiveJobId(jobId); + setDetailOpen(true); + }; + + const closeAssign = () => { + setAssignOpen(false); + setActiveJobId(null); + setVehicleValue(null); + }; + + const closeDetail = () => { + setDetailOpen(false); + setActiveJobId(null); + }; + + const handleAssign = () => { + const jobId = activeJobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id; + if (!jobId || !vehicleValue) { + toast({ + title: "Select a vehicle", + description: "Choose a vehicle to assign to this pickup.", + variant: "destructive", + }); + return; + } + + const vehicleLabel = + VEHICLE_OPTIONS.find((option) => option.value === vehicleValue)?.label ?? vehicleValue; + + setJobs((current) => + current.map((job) => + job.id === jobId + ? { ...job, status: "ASSIGNED", assignedVehicle: vehicleLabel } + : job, + ), + ); + + toast({ title: "Vehicle assigned", description: vehicleLabel }); + closeAssign(); + }; + + const handleAdvanceStatus = (job: FirstMileJob) => { + const next = NEXT_PICKUP_STATUS[job.pickupStatus]; + if (!next) return; + setJobs((current) => + current.map((item) => + item.id === job.id ? { ...item, pickupStatus: next } : item, + ), + ); + toast({ + title: "Status updated", + description: `${job.bookingRef} → ${PICKUP_STATUS_META[next].label}`, + }); + }; + + const handlePrintTripSlip = (job: FirstMileJob) => { + setTripSlipJob(job); + setTripSlipOpen(true); + }; + + const printTripSlip = () => { + if (!tripSlipJob) 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(tripSlipJob)); + win.document.close(); + }; + + const columns = useMemo((): ColumnDef[] => { + const headerClassName = ruleEngineTable.headerCell; + const cellClassName = ruleEngineTable.bodyCell; + return [ + { + id: "bookingRef", + header: "Booking", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + {row.original.bookingRef} + + ), + }, + { + id: "customer", + header: "Customer", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => row.original.customer, + }, + { + id: "pickup", + header: "Pickup", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => row.original.pickup, + }, + { + id: "cargo", + header: "Cargo", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => row.original.cargo, + }, + { + id: "price", + header: "Price", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => formatPrice(row.original.price), + }, + { + id: "vehicle", + header: "Vehicle", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => + row.original.assignedVehicle ?? —, + }, + { + id: "pickupStatus", + header: "Status", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const meta = PICKUP_STATUS_META[row.original.pickupStatus]; + return ( + + {meta.label} + + ); + }, + }, + { + id: "status", + header: "Assignment", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + {row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"} + + ), + }, + { + id: "actions", + header: "Actions", + meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, + cell: ({ row }) => { + const isAssigned = row.original.status === "ASSIGNED"; + const nextStatus = NEXT_PICKUP_STATUS[row.original.pickupStatus]; + const canPrintTripSlip = + row.original.pickupStatus === "READY_FOR_PICKUP" || + row.original.pickupStatus === "PICKED_UP"; + return ( + + + + + + + + + } + disabled={!nextStatus} + onClick={() => handleAdvanceStatus(row.original)} + > + {nextStatus + ? `Mark ${PICKUP_STATUS_META[nextStatus].label}` + : "Picked Up"} + + + } + disabled={isAssigned} + onClick={() => openAssign(row.original.id)} + > + Assign + + } + disabled={!isAssigned} + onClick={() => openAssign(row.original.id)} + > + Reassign + + + } + onClick={() => openDetail(row.original.id)} + > + View detail + + {canPrintTripSlip && ( + } + onClick={() => handlePrintTripSlip(row.original)} + > + Print trip slip + + )} + + + + ); + }, + }, + ]; + }, []); + + const tableStatus = "success" as const; + + return ( + + + + + + + setSearch(e.currentTarget.value)} + w={260} + /> + } + onClick={() => openAssign(null)} + > + Assign Mile + + + + {FILTER_OPTIONS.map((option) => { + const active = statusFilter === option.value; + return ( + { + setStatusFilter(option.value); + setPagination((p) => ({ ...p, pageIndex: 0 })); + }} + > + {option.label} ({statusCounts[option.value]}) + + ); + })} + + + + + ( + + )} + /> + + + + Assign Vehicle} + size="lg" + radius="lg" + centered + > + + {activeJob ? ( + + ) : ( + + No unassigned pickups available. + + )} + + + + + Cancel + + + {activeJob?.status === "ASSIGNED" ? "Reassign" : "Assign"} + + + + + + Pickup Detail} + size="lg" + radius="lg" + centered + > + + {activeJob && } + + + Close + + + + + + setTripSlipOpen(false)} + title={Trip Slip} + size="lg" + radius="lg" + centered + > + + {tripSlipJob && } + + + setTripSlipOpen(false)}> + Close + + } onClick={printTripSlip}> + Print + + + + + + ); +}; + +export default FirstMilePage;
First Mile Trip Slip