Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
natib21 d285d688b9 fix
2026-07-04 01:13:47 +00:00

1301 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { type ReactNode, useMemo, useState } from "react";
import {
ArrowRight,
ChevronRight,
Eye,
MoreHorizontal,
PackageCheck,
Printer,
Receipt,
RefreshCw,
Ruler,
Trash,
Truck,
} 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 { 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,
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) =>
`ETB ${amount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
const STATUS_META: Record<FirstMileApiStatus, { label: string; color: string }> = {
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<string, { label: string; color: string }> = {
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<Record<FirstMileApiStatus, FirstMileApiStatus>> = {
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 }) => (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="sm">{value}</Text>
</Stack>
);
const BookingInfo = ({ record }: { record: FirstMileRecord }) => {
const hasPickupAddress = record.booking?.firstMilePickupAddress != null;
return (
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>{bookingRef(record)}</Text>
<Group gap="xs">
<Badge color={STATUS_META[record.status].color} variant="light" size="sm">
{STATUS_META[record.status].label}
</Badge>
<Badge
color={isAssigned(record) ? "green" : "orange"}
variant="light"
size="sm"
>
{isAssigned(record) ? "Assigned" : "Unassigned"}
</Badge>
</Group>
</Group>
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Customer" value={customerName(record)} />
<InfoRow label="Service type" value={serviceTypeName(record)} />
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
<InfoRow label="Cargo" value={cargoDesc(record)} />
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
<InfoRow label="Contact" value={contactPersonName(record)} />
<InfoRow label="Phone" value={contactPhone(record)} />
<InfoRow label="Requested date" value={requestedDate(record)} />
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
</SimpleGrid>
</Stack>
</Card>
);
};
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 = () => (
<Box style={{ height: 96, display: "flex", alignItems: "center" }}>
<Box
style={{
width: 96,
height: 96,
borderRadius: "50%",
border: "2px solid var(--mantine-color-teal-7)",
transform: "rotate(-12deg)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box
style={{
width: 82,
height: 82,
borderRadius: "50%",
border: "1px solid var(--mantine-color-teal-7)",
color: "var(--mantine-color-teal-7)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
lineHeight: 1.1,
}}
>
<Text size="9px" fw={700} style={{ letterSpacing: 1 }}>
EDR FREIGHT
</Text>
<Text size="sm" fw={800}>
APPROVED
</Text>
<Text size="8px" fw={600}>
OPERATIONS
</Text>
</Box>
</Box>
</Box>
);
const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => (
<Stack gap="sm" style={{ flex: 1, position: "relative", minHeight: stamp ? 130 : undefined }}>
<Text fw={600} size="sm">
{title}
</Text>
<Group gap="xs" align="flex-end">
<Text size="sm" c="dimmed">Name:</Text>
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
</Group>
<Group gap="xs" align="flex-end">
<Text size="sm" c="dimmed">Signature:</Text>
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
</Group>
{stamp && (
<Box style={{ position: "absolute", right: 4, top: 22, opacity: 0.85, pointerEvents: "none" }}>
{stamp}
</Box>
)}
</Stack>
);
const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => (
<Stack gap="md">
<Stack gap={2} align="center">
<Text fw={700}>EDR Freight</Text>
<Text size="sm" c="dimmed" tt="uppercase" fw={600}>First Mile Trip Slip</Text>
</Stack>
<Group justify="space-between">
<Text size="sm" fw={600}>{bookingRef(record)}</Text>
<Text size="sm" c="dimmed">{requestedDate(record)}</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="xs">
{tripSlipRows(record).map(([label, value]) => (
<InfoRow key={label} label={label} value={value} />
))}
</SimpleGrid>
<Divider label="Acknowledgement" labelPosition="center" />
<Group align="flex-start" gap="xl" wrap="nowrap">
<SignatureBlock title="Driver" />
<SignatureBlock title="Operator" stamp={<SampleStamp />} />
</Group>
</Stack>
);
const escapeHtml = (v: string) =>
v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const buildTripSlipHtml = (record: FirstMileRecord) => {
const rows = tripSlipRows(record)
.map(([l, v]) => `<tr><td class="lbl">${escapeHtml(l)}</td><td>${escapeHtml(v)}</td></tr>`)
.join("");
const sig = (title: string, withStamp: boolean) => `
<div class="sign-col">
<div class="sign-title">${title}</div>
<div class="sign-field"><span>Name:</span><span class="line"></span></div>
<div class="sign-field"><span>Signature:</span><span class="line"></span></div>
${withStamp ? '<div class="stamp"><div class="ring"><div class="ring-inner"><span>EDR FREIGHT</span><strong>APPROVED</strong><span>OPERATIONS</span></div></div></div>' : ""}
</div>`;
return `<!doctype html><html><head><meta charset="utf-8" />
<title>Trip Slip ${escapeHtml(bookingRef(record))}</title>
<style>
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; color: #111; margin: 32px; }
.head { text-align: center; margin-bottom: 16px; }
.head h1 { font-size: 18px; margin: 0; }
.head p { font-size: 12px; letter-spacing: 1px; text-transform: uppercase; color: #555; margin: 2px 0 0; }
.meta { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 8px 0 20px; }
td { padding: 5px 6px; border-bottom: 1px solid #eee; vertical-align: top; }
td.lbl { color: #666; text-transform: uppercase; font-size: 11px; font-weight: 700; width: 40%; }
.ack { text-align: center; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #777; margin: 16px 0 8px; }
.signs { display: flex; gap: 32px; }
.sign-col { flex: 1; position: relative; min-height: 130px; }
.sign-title { font-weight: 700; font-size: 13px; margin-bottom: 12px; }
.sign-field { display: flex; gap: 6px; align-items: flex-end; font-size: 12px; color: #666; margin-bottom: 10px; }
.sign-field .line { flex: 1; border-bottom: 1px solid #888; height: 16px; }
.stamp { position: absolute; right: 4px; top: 22px; opacity: 0.85; }
.ring { width: 96px; height: 96px; border-radius: 50%; border: 2px solid #0c7a57; transform: rotate(-12deg); display: flex; align-items: center; justify-content: center; }
.ring-inner { width: 82px; height: 82px; border-radius: 50%; border: 1px solid #0c7a57; color: #0c7a57; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; line-height: 1.1; }
.ring-inner span { font-size: 9px; font-weight: 700; letter-spacing: 1px; }
.ring-inner strong { font-size: 13px; font-weight: 800; }
</style></head>
<body onload="window.print()">
<div class="head"><h1>EDR Freight</h1><p>First Mile Trip Slip</p></div>
<div class="meta"><span>${escapeHtml(bookingRef(record))}</span><span>${escapeHtml(requestedDate(record))}</span></div>
<table>${rows}</table>
<div class="ack">Acknowledgement</div>
<div class="signs">${sig("Driver", false)}${sig("Operator", true)}</div>
</body></html>`;
};
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<StatusFilter>("ALL");
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
const [bulkMode, setBulkMode] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [tripSlipOpen, setTripSlipOpen] = useState(false);
const [tripSlipRecord, setTripSlipRecord] = useState<FirstMileRecord | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
const [acceptOpen, setAcceptOpen] = useState(false);
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
const [selectedBooking, setSelectedBooking] = useState<BookingDetail | null>(null);
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
const [bookingSearch, setBookingSearch] = useState("");
const [distanceOpen, setDistanceOpen] = useState(false);
const [distanceValue, setDistanceValue] = useState("");
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(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 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 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],
);
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) => {
setActiveId(id);
setDistanceValue("");
setDistanceOpen(true);
};
const closeDistance = () => {
setDistanceOpen(false);
setActiveId(null);
setDistanceValue("");
};
const openWarehouseReceive = (record: FirstMileRecord) => {
setWarehouseReceiveRecord(record);
setWarehouseReceiveOpen(true);
};
const closeWarehouseReceive = () => {
setWarehouseReceiveOpen(false);
setWarehouseReceiveRecord(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) => {
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<StatusFilter, number> = {
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<FirstMileRecord>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "select",
size: 40,
meta: { headerClassName, cellClassName },
header: ({ table }) => (
<Checkbox
aria-label="Select all"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
onChange={(e) => table.toggleAllPageRowsSelected(e.currentTarget.checked)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: "bookingRef",
header: "Booking",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <Text size="sm" fw={600}>{bookingRef(row.original)}</Text>,
},
{
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),
},
{
id: "vehicle",
header: "Vehicle",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
vehicleLabel(row.original) ?? <Text c="dimmed">Unassigned</Text>,
},
{
id: "exactKm",
header: "Actual Distance (KM)",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed"></Text>,
},
{
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 <Text c="dimmed"></Text>;
}
const status = String((row.original as any).paid ? "PAID" : invoice.status || "").toUpperCase();
const badge = INVOICE_STATUS_META[status] ?? { color: "gray", label: status || "—" };
return (
<Group gap="xs" wrap="nowrap">
<UnstyledButton
onClick={() =>
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" }}
>
<Tooltip label={invoice.number} withArrow>
<span>{shortInvoiceNo(invoice.number)}</span>
</Tooltip>
</UnstyledButton>
{status && <Badge color={badge.color} variant="filled" size="sm">{badge.label}</Badge>}
</Group>
);
},
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return <Badge color={meta.color} variant="light" size="sm">{meta.label}</Badge>;
},
},
{
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 (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Menu
position="bottom-end"
width={200}
withinPortal
styles={{ dropdown: { maxHeight: 320, overflowY: "auto" } }}
>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Pickup actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
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}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Truck size={15} />}
disabled={assigned}
onClick={() => openAssign(row.original.id)}
>
Assign
</Menu.Item>
<Menu.Item
leftSection={<RefreshCw size={15} />}
disabled={!assigned}
onClick={() => openAssign(row.original.id)}
>
Reassign
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Eye size={15} />}
onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }}
>
View detail
</Menu.Item>
<Menu.Item
leftSection={<PackageCheck size={15} />}
disabled={!canReceiveToWarehouse}
onClick={() => openWarehouseReceive(row.original)}
>
Receive to warehouse
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
disabled={Boolean(row.original.invoice)}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
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"}
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
onClick={() => handlePrintTripSlip(row.original)}
>
Print trip slip
</Menu.Item>
)}
{!isPaid && (
<>
<Menu.Divider />
<Menu.Item
leftSection={<Trash size={15} />}
color="red"
disabled={Boolean(row.original.invoice)}
onClick={() => {
if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) {
deleteMutation.mutate(row.original.id);
}
}}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vehicleOptions]);
return (
<Stack gap="md" p="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%">
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<TextInput
placeholder="Search pickups…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={260}
/>
<Group gap="sm">
{selectedIds.length > 0 && (
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign} styles={{ label: { fontWeight: 500 } }}>
Assign vehicle ({selectedIds.length})
</Button>
)}
<Button leftSection={<Truck size={16} />} onClick={openAccept} styles={{ label: { fontWeight: 500 } }}>
Assign Mile
</Button>
</Group>
</Group>
<Group gap="xs" wrap="wrap">
{FILTER_OPTIONS.map((option) => {
const active = statusFilter === option.value;
return (
<Button
key={option.value}
size="xs"
variant={active ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setStatusFilter(option.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
{option.label} ({statusCounts[option.value] ?? 0})
</Button>
);
})}
<Button
size="xs"
variant={filterPostPaymentPending ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setFilterPostPaymentPending(!filterPostPaymentPending);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
Post Payment Pending
</Button>
</Group>
</Stack>
</Box>
<DataTable
columns={columns}
data={pagedRecords}
status={isLoading ? "loading" : "success"}
emptyMessage="No first-mile pickups found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRecords.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
enableRowSelection: true,
getRowId: (row) => row.id,
state: { pagination, rowSelection },
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: fp }) => (
<DataTableFooter table={table} pagination={fp} options={{ labels: { items: "pickups" } }} />
)}
/>
</Stack>
</Card>
{/* Assign / Reassign modal */}
<Modal
opened={assignOpen}
onClose={closeAssign}
title={<Text fw={600}>Assign Vehicle</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{bulkMode ? (
<Text size="sm">
Assigning a vehicle to{" "}
<Text span fw={600}>{selectedIds.length}</Text>{" "}
selected {selectedIds.length === 1 ? "pickup" : "pickups"}.
</Text>
) : activeRecord ? (
<BookingInfo record={activeRecord} />
) : (
<Text size="sm" c="dimmed">No unassigned pickups available.</Text>
)}
<Divider />
<Select
label="Vehicle"
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
description={
vehicleOptions.length === 0
? "No free vehicles available — all vehicles are busy, in maintenance, or retired. Free up a vehicle in Fleet Vehicles first."
: undefined
}
data={vehicleOptions}
value={vehicleValue}
onChange={setVehicleValue}
searchable
disabled={vehicleOptions.length === 0}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>Cancel</Button>
<Button
onClick={handleAssign}
loading={updateMutation.isPending}
disabled={bulkMode ? selectedIds.length === 0 : !activeRecord}
>
{!bulkMode && activeRecord && isAssigned(activeRecord) ? "Reassign" : "Assign"}
</Button>
</Group>
</Stack>
</Modal>
{/* View detail modal */}
<Modal
opened={detailOpen}
onClose={() => { setDetailOpen(false); setActiveId(null); }}
title={<Text fw={600}>Pickup Detail</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && <BookingInfo record={activeRecord} />}
<Group justify="flex-end">
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
</Group>
</Stack>
</Modal>
{/* Trip slip modal */}
<Modal
opened={tripSlipOpen}
onClose={() => setTripSlipOpen(false)}
title={<Text fw={600}>Trip Slip</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{tripSlipRecord && <TripSlipDocument record={tripSlipRecord} />}
<Divider />
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setTripSlipOpen(false)}>Close</Button>
<Button leftSection={<Printer size={16} />} onClick={printTripSlip}>Print</Button>
</Group>
</Stack>
</Modal>
<ReceiveInventoryModal
opened={warehouseReceiveOpen}
onClose={closeWarehouseReceive}
mode="bulk"
direction="EXPORT"
bookingId={warehouseReceiveRecord?.bookingId}
bookingLabel={warehouseReceiveRecord ? bookingRef(warehouseReceiveRecord) : undefined}
onReceived={() => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
closeWarehouseReceive();
}}
/>
{/* Accept Booking modal — step 1: booking list, step 2: details + vehicle */}
<Modal
opened={acceptOpen}
onClose={closeAccept}
title={
<Text fw={600}>
{acceptStep === 1 ? "Select a Paid Booking" : "Accept Booking"}
</Text>
}
size="xl"
radius="lg"
centered
>
{acceptStep === 1 ? (
<Stack gap="md">
<TextInput
placeholder="Search by reference or company…"
value={bookingSearch}
onChange={(e) => setBookingSearch(e.currentTarget.value)}
/>
<Stack gap="xs" style={{ maxHeight: 420, overflowY: "auto" }}>
{bookingsLoading ? (
<Text c="dimmed" size="sm" ta="center" py="md">Loading bookings</Text>
) : filteredPaidBookings.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" py="md">No paid export bookings need first mile.</Text>
) : (
filteredPaidBookings.map((b) => (
<UnstyledButton
key={b.id}
w="100%"
onClick={() => {
setSelectedBooking(b);
setAcceptStep(2);
}}
style={{
borderRadius: "var(--mantine-radius-md)",
border: "1px solid var(--mantine-color-gray-3)",
padding: "10px 12px",
backgroundColor: "var(--mantine-color-white)",
transition: "background-color 120ms ease, border-color 120ms ease",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
"var(--mantine-color-blue-0)";
(e.currentTarget as HTMLButtonElement).style.borderColor =
"var(--mantine-color-blue-4)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
"var(--mantine-color-white)";
(e.currentTarget as HTMLButtonElement).style.borderColor =
"var(--mantine-color-gray-3)";
}}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Stack gap={3} style={{ flex: 1, minWidth: 0 }}>
<Text fw={700} size="sm" c="dark">{b.reference}</Text>
<Text size="xs" c="dimmed" truncate>
{b.company?.name ?? b.company?.companyName ?? "—"}
</Text>
</Stack>
<Stack gap={3} align="flex-end" style={{ flexShrink: 0 }}>
<Text size="xs" c="dimmed">
{b.originYard?.label ?? "—"} {b.destinationYard?.label ?? "—"}
</Text>
<Text size="sm" fw={600} c="blue">
{formatPrice(b.totalAmount)}
</Text>
<Text size="xs" c="dimmed">
{b.scheduledDate ? b.scheduledDate.slice(0, 10) : "—"}
</Text>
</Stack>
<ChevronRight size={16} color="var(--mantine-color-gray-5)" />
</Group>
</UnstyledButton>
))
)}
</Stack>
<Group justify="flex-end">
<Button variant="default" onClick={closeAccept}>Cancel</Button>
</Group>
</Stack>
) : (
<Stack gap="md">
{selectedBooking && (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>{selectedBooking.reference}</Text>
<Badge color="green" variant="light" size="sm">PAID</Badge>
</Group>
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Customer" value={selectedBooking.company?.name ?? selectedBooking.company?.companyName ?? "—"} />
<InfoRow label="Service type" value={selectedBooking.serviceType?.name ?? "—"} />
<InfoRow label="Pickup address" value={selectedBooking.firstMilePickupAddress ?? "—"} />
<InfoRow label="Destination (origin yard)" value={selectedBooking.originYard?.label ?? "—"} />
<InfoRow label="Train destination yard" value={selectedBooking.destinationYard?.label ?? "—"} />
<InfoRow label="Cargo type" value={selectedBooking.cargoType?.name ?? "—"} />
<InfoRow label="Weight (VGM)" value={`${selectedBooking.cargoTotalWeightVgm} t`} />
<InfoRow label="Total amount" value={formatPrice(selectedBooking.totalAmount)} />
<InfoRow label="Contact person" value={selectedBooking.company?.contactPersonName ?? "—"} />
<InfoRow label="Contact phone" value={selectedBooking.company?.contactPersonPhone ?? selectedBooking.company?.phone ?? "—"} />
<InfoRow label="Requested date" value={selectedBooking.scheduledDate ? selectedBooking.scheduledDate.slice(0, 10) : "—"} />
</SimpleGrid>
</Stack>
</Card>
)}
<Divider />
<Select
label="Assign Vehicle (optional)"
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
description={
vehicleOptions.length === 0
? "No free vehicles available — you can still accept and assign a vehicle later."
: undefined
}
data={vehicleOptions}
value={acceptVehicleValue}
onChange={setAcceptVehicleValue}
searchable
clearable
disabled={vehicleOptions.length === 0}
/>
<Group justify="space-between" gap="sm">
<Button variant="subtle" onClick={() => setAcceptStep(1)}> Back</Button>
<Group gap="sm">
<Button variant="default" onClick={closeAccept}>Cancel</Button>
<Button
onClick={handleAcceptConfirm}
loading={acceptMutation.isPending}
disabled={!selectedBooking}
>
Accept Booking
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
{/* Add Actual Distance modal */}
<Modal
opened={distanceOpen}
onClose={closeDistance}
title={<Text fw={600}>Add Actual Distance</Text>}
size="md"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Group justify="space-between">
<Text size="xs" c="dimmed">Customer</Text>
<Text size="sm">{customerName(activeRecord)}</Text>
</Group>
<Group justify="space-between">
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
</Group>
</Stack>
</Card>
)}
<NumberInput
label="Actual Distance (KM)"
placeholder="Enter distance"
value={distanceValue}
onChange={(v) => setDistanceValue(String(v ?? ""))}
min={0}
step={0.1}
decimalScale={2}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeDistance}>Cancel</Button>
<Button
onClick={handleSaveDistance}
loading={updateDistanceMutation.isPending}
disabled={!distanceValue}
>
Save
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default FirstMilePage;