Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
natib21 34b87bbcee fix
2026-07-02 14:06:45 +00:00

1423 lines
54 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,
Printer,
RefreshCw,
Ruler,
Trash,
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<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" },
};
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 { 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 [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | 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: "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() });
},
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 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) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data),
onSuccess: () => {
toast({ title: "Containers allocated" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(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) => {
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 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) => {
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: "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) ?? <Text c="dimmed"></Text>,
},
{
id: "estimatedKm",
header: "Est. Distance (KM)",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : <Text c="dimmed"></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 }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
const isPaid = (row.original as any).paid;
if (!hasDistance) {
return <Text c="dimmed"></Text>;
}
if (isPaid) {
return (
<Group gap="xs" wrap="nowrap">
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
<Badge color="green" variant="light" size="sm">Paid</Badge>
</Group>
);
}
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
);
},
},
{
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: "assignment",
header: "Assignment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={isAssigned(row.original) ? "green" : "orange"} variant="light" size="sm">
{isAssigned(row.original) ? "Assigned" : "Unassigned"}
</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;
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="Pickup actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
disabled={!nextStatus}
onClick={() => handleAdvanceStatus(row.original)}
>
{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={<Ruler size={15} />}
onClick={() => openDistance(row.original.id)}
>
Add distance
</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"
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">
<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>
{/* 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>
{/* Invoice modal */}
<Modal
opened={invoiceOpen}
onClose={closeInvoice}
title={<Text fw={600}>Invoice #345</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{invoiceRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700}>EDR Freight</Text>
<Text fw={600} size="sm">Invoice #345</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
<InfoRow label="Pickup" value={pickupLocation(invoiceRecord)} />
<InfoRow label="Destination" value={destinationYardName(invoiceRecord)} />
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
</SimpleGrid>
<Divider />
<Group justify="flex-end">
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Post Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
</Group>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Advanced Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
</Group>
<Divider my="xs" />
{(() => {
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
const difference = postPayment - advancedPayment;
if (difference > 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Remaining to Pay</Text>
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
</Group>
);
} else if (difference < 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Refund</Text>
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
</Group>
);
} else {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Status</Text>
<Text fw={700} c="blue">Settled</Text>
</Group>
);
}
})()}
</Stack>
</Group>
</Stack>
</Card>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeInvoice}>Close</Button>
</Group>
</Stack>
</Modal>
{/* Container Allocation modal */}
<Modal
opened={containerAllocationOpen}
onClose={closeContainerAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
{/* Capacity guidance */}
{activeRecord.booking?.cargoType?.label === "BULK" ? (
<Alert color="blue" title="Bulk Cargo Allocation">
<Text size="sm">
Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows.
</Text>
<Text size="xs" c="dimmed" mt="xs">
Capacity: TBD TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
</Alert>
) : (
<Alert color="blue">
<Text size="sm">
One vehicle per container. Each container will be assigned to a single vehicle.
</Text>
</Alert>
)}
<Divider />
{/* Container table */}
<FirstMileContainerAllocationTable
firstMileId={activeRecord.id}
containers={[
// TODO: Get containers from booking/first-mile data
// For now placeholder with TODO comment
]}
onSave={async (allocations) => {
await allocateMutation.mutateAsync(allocations);
}}
/>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeContainerAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default FirstMilePage;