mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
565 lines
17 KiB
TypeScript
565 lines
17 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import {
|
|
ArrowRight,
|
|
Check,
|
|
Eye,
|
|
Filter,
|
|
MoreHorizontal,
|
|
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 LastMileStatus = "UNASSIGNED" | "ASSIGNED";
|
|
type DeliveryStatus = "PAYMENT_PENDING" | "READY_TO_TRANSIT" | "DELIVERED";
|
|
|
|
interface LastMileJob {
|
|
id: string;
|
|
bookingRef: string;
|
|
customer: string;
|
|
destination: string;
|
|
cargo: string;
|
|
status: LastMileStatus;
|
|
deliveryStatus: DeliveryStatus;
|
|
assignedVehicle: string | null;
|
|
// Booking info shown in the Assign / View Detail modals.
|
|
serviceType: string;
|
|
weight: string;
|
|
originYard: string;
|
|
contactName: string;
|
|
contactPhone: string;
|
|
requestedDate: string;
|
|
}
|
|
|
|
const DELIVERY_STATUS_META: Record<
|
|
DeliveryStatus,
|
|
{ label: string; color: string }
|
|
> = {
|
|
PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" },
|
|
READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" },
|
|
DELIVERED: { label: "Delivered", color: "green" },
|
|
};
|
|
|
|
// Forward-only lifecycle: Payment Pending → Ready to Transit → Delivered.
|
|
const NEXT_DELIVERY_STATUS: Partial<Record<DeliveryStatus, DeliveryStatus>> = {
|
|
PAYMENT_PENDING: "READY_TO_TRANSIT",
|
|
READY_TO_TRANSIT: "DELIVERED",
|
|
};
|
|
|
|
// Single filter covering both the delivery lifecycle and assignment state.
|
|
type StatusFilter =
|
|
| "ALL"
|
|
| DeliveryStatus
|
|
| LastMileStatus;
|
|
|
|
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
|
{ value: "ALL", label: "All statuses" },
|
|
{ value: "PAYMENT_PENDING", label: "Payment Pending" },
|
|
{ value: "READY_TO_TRANSIT", label: "Ready to Transit" },
|
|
{ value: "DELIVERED", label: "Delivered" },
|
|
{ value: "ASSIGNED", label: "Assigned" },
|
|
{ value: "UNASSIGNED", label: "Unassigned" },
|
|
];
|
|
|
|
// 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",
|
|
deliveryStatus: "PAYMENT_PENDING",
|
|
assignedVehicle: null,
|
|
serviceType: "Door-to-door (Last Mile)",
|
|
weight: "12.4 t",
|
|
originYard: "Indode Dry Port",
|
|
contactName: "Selam Bekele",
|
|
contactPhone: "+251 911 234 567",
|
|
requestedDate: "2026-06-22",
|
|
},
|
|
{
|
|
id: "2",
|
|
bookingRef: "BK-10238",
|
|
customer: "Dire Logistics",
|
|
destination: "Industry Zone, Dire Dawa",
|
|
cargo: "Bulk · 18t Cement",
|
|
status: "ASSIGNED",
|
|
deliveryStatus: "READY_TO_TRANSIT",
|
|
assignedVehicle: "Isuzu FVR (3-AA-45821)",
|
|
serviceType: "Terminal-to-door (Last Mile)",
|
|
weight: "18.0 t",
|
|
originYard: "Dire Dawa Terminal",
|
|
contactName: "Yonas Tadesse",
|
|
contactPhone: "+251 912 887 010",
|
|
requestedDate: "2026-06-21",
|
|
},
|
|
{
|
|
id: "3",
|
|
bookingRef: "BK-10233",
|
|
customer: "Horizon Imports",
|
|
destination: "Kality Terminal, Addis Ababa",
|
|
cargo: "40ft container · Machinery",
|
|
status: "UNASSIGNED",
|
|
deliveryStatus: "DELIVERED",
|
|
assignedVehicle: null,
|
|
serviceType: "Door-to-door (Last Mile)",
|
|
weight: "24.7 t",
|
|
originYard: "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 }) => (
|
|
<Stack gap={2}>
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
|
{label}
|
|
</Text>
|
|
<Text size="sm">{value}</Text>
|
|
</Stack>
|
|
);
|
|
|
|
const BookingInfo = ({ job }: { job: LastMileJob }) => (
|
|
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
|
|
<Stack gap="sm">
|
|
<Group justify="space-between">
|
|
<Text fw={600}>{job.bookingRef}</Text>
|
|
<Group gap="xs">
|
|
<Badge
|
|
color={DELIVERY_STATUS_META[job.deliveryStatus].color}
|
|
variant="light"
|
|
size="sm"
|
|
>
|
|
{DELIVERY_STATUS_META[job.deliveryStatus].label}
|
|
</Badge>
|
|
<Badge
|
|
color={job.status === "ASSIGNED" ? "green" : "orange"}
|
|
variant="light"
|
|
size="sm"
|
|
>
|
|
{job.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
|
|
</Badge>
|
|
</Group>
|
|
</Group>
|
|
<SimpleGrid cols={2} spacing="sm">
|
|
<InfoRow label="Customer" value={job.customer} />
|
|
<InfoRow label="Service type" value={job.serviceType} />
|
|
<InfoRow label="Origin yard" value={job.originYard} />
|
|
<InfoRow label="Destination" value={job.destination} />
|
|
<InfoRow label="Cargo" value={job.cargo} />
|
|
<InfoRow label="Weight" value={job.weight} />
|
|
<InfoRow label="Contact" value={job.contactName} />
|
|
<InfoRow label="Phone" value={job.contactPhone} />
|
|
<InfoRow label="Requested date" value={job.requestedDate} />
|
|
<InfoRow label="Assigned vehicle" value={job.assignedVehicle ?? "—"} />
|
|
</SimpleGrid>
|
|
</Stack>
|
|
</Card>
|
|
);
|
|
|
|
const LastMilePage = () => {
|
|
const { toast } = useToast();
|
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
|
|
const [jobs, setJobs] = useState<LastMileJob[]>(PLACEHOLDER_JOBS);
|
|
const [search, setSearch] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
|
|
|
|
const [assignOpen, setAssignOpen] = useState(false);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [activeJobId, setActiveJobId] = useState<string | null>(null);
|
|
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
|
|
|
|
const activeJob = useMemo(
|
|
() => jobs.find((job) => job.id === activeJobId) ?? null,
|
|
[jobs, activeJobId],
|
|
);
|
|
|
|
const matchesStatusFilter = (job: LastMileJob) => {
|
|
switch (statusFilter) {
|
|
case "ALL":
|
|
return true;
|
|
case "ASSIGNED":
|
|
case "UNASSIGNED":
|
|
return job.status === statusFilter;
|
|
default:
|
|
return job.deliveryStatus === statusFilter;
|
|
}
|
|
};
|
|
|
|
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.destination, 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 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 handleAdvanceStatus = (job: LastMileJob) => {
|
|
const next = NEXT_DELIVERY_STATUS[job.deliveryStatus];
|
|
if (!next) return;
|
|
setJobs((current) =>
|
|
current.map((item) =>
|
|
item.id === job.id ? { ...item, deliveryStatus: next } : item,
|
|
),
|
|
);
|
|
toast({
|
|
title: "Status updated",
|
|
description: `${job.bookingRef} → ${DELIVERY_STATUS_META[next].label}`,
|
|
});
|
|
};
|
|
|
|
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: "deliveryStatus",
|
|
header: "Status",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => {
|
|
const meta = DELIVERY_STATUS_META[row.original.deliveryStatus];
|
|
return (
|
|
<Badge color={meta.color} variant="light" size="sm">
|
|
{meta.label}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Assignment",
|
|
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 }) => {
|
|
const isAssigned = row.original.status === "ASSIGNED";
|
|
const nextStatus = NEXT_DELIVERY_STATUS[row.original.deliveryStatus];
|
|
return (
|
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
|
<Menu position="bottom-end" width={200} withinPortal>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" color="gray" aria-label="Delivery actions">
|
|
<MoreHorizontal size={16} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
leftSection={<ArrowRight size={15} />}
|
|
disabled={!nextStatus}
|
|
onClick={() => handleAdvanceStatus(row.original)}
|
|
>
|
|
{nextStatus
|
|
? `Mark ${DELIVERY_STATUS_META[nextStatus].label}`
|
|
: "Delivered"}
|
|
</Menu.Item>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<Truck size={15} />}
|
|
disabled={isAssigned}
|
|
onClick={() => openAssign(row.original.id)}
|
|
>
|
|
Assign
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<RefreshCw size={15} />}
|
|
disabled={!isAssigned}
|
|
onClick={() => openAssign(row.original.id)}
|
|
>
|
|
Reassign
|
|
</Menu.Item>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<Eye size={15} />}
|
|
onClick={() => openDetail(row.original.id)}
|
|
>
|
|
View detail
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</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">
|
|
<Group gap="sm" wrap="wrap">
|
|
<TextInput
|
|
placeholder="Search deliveries…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
w={260}
|
|
/>
|
|
<Menu position="bottom-start" width={200} withinPortal>
|
|
<Menu.Target>
|
|
<Button variant="default" leftSection={<Filter size={16} />}>
|
|
{FILTER_OPTIONS.find((o) => o.value === statusFilter)?.label ??
|
|
"All statuses"}
|
|
</Button>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
{FILTER_OPTIONS.map((option) => (
|
|
<Menu.Item
|
|
key={option.value}
|
|
rightSection={
|
|
statusFilter === option.value ? <Check size={14} /> : null
|
|
}
|
|
onClick={() => {
|
|
setStatusFilter(option.value);
|
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
|
}}
|
|
>
|
|
{option.label}
|
|
</Menu.Item>
|
|
))}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</Group>
|
|
<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>}
|
|
size="lg"
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
{activeJob ? (
|
|
<BookingInfo job={activeJob} />
|
|
) : (
|
|
<Text size="sm" c="dimmed">
|
|
No unassigned deliveries available.
|
|
</Text>
|
|
)}
|
|
<Divider />
|
|
<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} disabled={!activeJob}>
|
|
{activeJob?.status === "ASSIGNED" ? "Reassign" : "Assign"}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={detailOpen}
|
|
onClose={closeDetail}
|
|
title={<Text fw={600}>Delivery Detail</Text>}
|
|
size="lg"
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
{activeJob && <BookingInfo job={activeJob} />}
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={closeDetail}>
|
|
Close
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export default LastMilePage;
|