last mile

This commit is contained in:
natib21
2026-06-20 12:36:13 +00:00
parent c25341fcaa
commit 28dd7f9c72
3 changed files with 317 additions and 0 deletions

View File

@@ -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 LastMilePage from "./pages/operations/LastMilePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
import { RequirePermission } from "./components/auth/RequirePermission";
@@ -113,6 +114,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
],
},
{
@@ -408,6 +415,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="operations/last-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<LastMilePage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={

View File

@@ -50,6 +50,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "View booking payment transactions",
},
},
{
prefix: "/dashboard/operations/last-mile",
meta: {
title: "Last Mile",
subtitle: "Assign vehicles to final-leg deliveries",
},
},
{
prefix: "/dashboard/operations/train-scheduling-v2/",
meta: {

View File

@@ -0,0 +1,295 @@
import { useMemo, useState } from "react";
import { Truck } from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
Badge,
Box,
Button,
Card,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useToast } from "@/hooks/use-toast";
type LastMileStatus = "UNASSIGNED" | "ASSIGNED";
interface LastMileJob {
id: string;
bookingRef: string;
customer: string;
destination: string;
cargo: string;
status: LastMileStatus;
assignedVehicle: string | null;
}
// Placeholder data — replace with a real last-mile service once the API exists.
const PLACEHOLDER_JOBS: LastMileJob[] = [
{
id: "1",
bookingRef: "BK-10241",
customer: "Awash Trading PLC",
destination: "Bole Sub-city, Addis Ababa",
cargo: "20ft container · Electronics",
status: "UNASSIGNED",
assignedVehicle: null,
},
{
id: "2",
bookingRef: "BK-10238",
customer: "Dire Logistics",
destination: "Industry Zone, Dire Dawa",
cargo: "Bulk · 18t Cement",
status: "ASSIGNED",
assignedVehicle: "Isuzu FVR (3-AA-45821)",
},
{
id: "3",
bookingRef: "BK-10233",
customer: "Horizon Imports",
destination: "Kality Terminal, Addis Ababa",
cargo: "40ft container · Machinery",
status: "UNASSIGNED",
assignedVehicle: null,
},
];
// 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 LastMilePage = () => {
const { toast } = useToast();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [jobs, setJobs] = useState<LastMileJob[]>(PLACEHOLDER_JOBS);
const [search, setSearch] = useState("");
const [assignOpen, setAssignOpen] = useState(false);
const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
const filteredJobs = useMemo(() => {
const term = search.trim().toLowerCase();
if (!term) return jobs;
return jobs.filter((job) =>
[job.bookingRef, job.customer, job.destination, job.cargo]
.join(" ")
.toLowerCase()
.includes(term),
);
}, [jobs, search]);
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) => {
setActiveJobId(jobId);
setVehicleValue(null);
setAssignOpen(true);
};
const closeAssign = () => {
setAssignOpen(false);
setActiveJobId(null);
setVehicleValue(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 delivery.",
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 columns = useMemo((): ColumnDef<LastMileJob>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "bookingRef",
header: "Booking",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={600}>
{row.original.bookingRef}
</Text>
),
},
{
id: "customer",
header: "Customer",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.customer,
},
{
id: "destination",
header: "Destination",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.destination,
},
{
id: "cargo",
header: "Cargo",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.cargo,
},
{
id: "vehicle",
header: "Vehicle",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.assignedVehicle ?? <Text c="dimmed"></Text>,
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge
color={row.original.status === "ASSIGNED" ? "green" : "orange"}
variant="light"
size="sm"
>
{row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
</Badge>
),
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Button
variant="light"
size="compact-sm"
leftSection={<Truck size={14} />}
onClick={() => openAssign(row.original.id)}
>
{row.original.status === "ASSIGNED" ? "Reassign" : "Assign"}
</Button>
</Group>
),
},
];
}, []);
const tableStatus = "success" as const;
return (
<Stack gap="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%">
<Group justify="space-between" wrap="wrap">
<TextInput
placeholder="Search deliveries…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={260}
/>
<Button
leftSection={<Truck size={16} />}
onClick={() => openAssign(null)}
>
Assign Mile
</Button>
</Group>
</Box>
<DataTable
columns={columns}
data={pagedJobs}
status={tableStatus}
emptyMessage="No last-mile deliveries found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredJobs.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "deliveries" } }}
/>
)}
/>
</Stack>
</Card>
<Modal
opened={assignOpen}
onClose={closeAssign}
title={<Text fw={600}>Assign Vehicle</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Select
label="Vehicle"
placeholder="Select a vehicle"
data={VEHICLE_OPTIONS}
value={vehicleValue}
onChange={setVehicleValue}
searchable
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>
Cancel
</Button>
<Button onClick={handleAssign}>Assign</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default LastMilePage;