import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams, useSearchParams, } from "react-router-dom"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, Badge, Box, Button, Center, Divider, FileButton, Group, Loader, Modal, NumberInput, Paper, Select, Stack, Switch, Text, Textarea, TextInput, ThemeIcon, } from "@mantine/core"; import { AlertCircle, AlertTriangle, CalendarDays, CheckCircle2, ChevronLeft, FileDown, FileText, FileUp, MapPin, Package, Receipt, Repeat, X, } from "lucide-react"; import type { Freight } from "@edr/types"; import { OperationDatePicker } from "@edr/ui-common"; import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; import { contractsService } from "@/services/contracts.service"; import { useContractDetail, useContractMutations, } from "@/hooks/contracts/useContracts"; import { computeGlShipmentTotal, formatRateUnit, type GlShipmentQuantities, } from "./gl-booking-form/total"; import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice"; import { downloadContainerImportTemplate, parseContainerExcel, } from "./gl-booking-form/container-excel"; import { fieldStyles, StepCard, StepHeader, StepLabel, } from "./gl-booking-form/form-ui"; /** All booking-window times are communicated in East Africa Time. */ const EAT_TZ = "Africa/Addis_Ababa"; // ISO 6346: 4-letter owner/category code + 6-digit serial + check digit. // Same rule the customer portal shipment form enforces. const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; interface UnitErrors { containerNumber?: string; vgmTons?: string; } interface BulkErrors { quantity?: string; hazardous?: string; reefer?: string; } function fmtWindowOpensAt(iso: string): string { const date = new Date(iso).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: EAT_TZ, }); const time = new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: EAT_TZ, }); return `${date} · ${time}`; } interface UnitDraft { containerNumber: string; sealNumber: string; vgmTons: number | string; /** Per-unit flags — the line's hazardous/reefer counts are derived from these. */ hazardous: boolean; reefer: boolean; } interface ContainerLineDraft { containerSize: string; units: UnitDraft[]; } interface BulkLineDraft { cargoTypeId: string; cargoWeightTons: number | string; itemCount: number | string; hazardousQuantity: number | string; reeferQuantity: number | string; } function emptyUnit(): UnitDraft { return { containerNumber: "", sealNumber: "", vgmTons: "", hazardous: false, reefer: false, }; } function bulkUnitOfMeasure( contract: Freight.IContract, ): "PER_TON" | "PER_ITEM" { const hasPerItem = contract.pricingBreakdown?.lineItems?.some( (li) => li.unit === "per_item", ); return hasPerItem ? "PER_ITEM" : "PER_TON"; } export default function GlCreateBookingForm() { const { id } = useParams<{ id: string }>(); const [searchParams] = useSearchParams(); const requestId = searchParams.get("requestId"); const navigate = useNavigate(); const { data: contract, isLoading } = useContractDetail(id); const mutations = useContractMutations(id ?? ""); const { data: bookingRequest } = useQuery({ queryKey: ["shipment-request", requestId], queryFn: () => contractsService.getBookingRequest(requestId!), enabled: Boolean(requestId), }); // Same window-gating the customer sees: GL may only create a booking while a // booking window is OPEN for one of the contract's routes. const contractId = contract?.id ?? id; const { data: bookingWindows, isLoading: windowsLoading } = useQuery({ ...api.trainScheduling.contractBookingWindows.queryOptions({ input: { contractId: contractId ?? "" }, }), enabled: Boolean(contractId), }); const windowOpen = useMemo( () => (bookingWindows ?? []).some((w) => w.isOpenNow), [bookingWindows], ); // Next future window across all routes, used for the "next window" notice — // the train dispatching soonest among those not yet open, matching the // departure-date ordering of the window cards. const nextWindow = useMemo(() => { const now = Date.now(); return (bookingWindows ?? []) .filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now) .sort((a, b) => { const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; if (da !== db) return da - db; return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(); })[0]; }, [bookingWindows]); const [scheduledDate, setScheduledDate] = useState(""); const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); const [containerLines, setContainerLines] = useState([]); const [bulkLines, setBulkLines] = useState([]); const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); const seededRef = useRef(false); const returnSeededRef = useRef(false); // Seed the equipment-return toggle from the contract exactly once (also when // the form is prefilled from a shipment request); GL can flip it per shipment. useEffect(() => { if (!contract || returnSeededRef.current) return; returnSeededRef.current = true; setWithReturn(contract.equipmentReturn === "WITH_RETURN"); }, [contract]); const isContainer = contract?.freightType === "CONTAINER"; const routes = useMemo( () => [...(contract?.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder), [contract?.routes], ); const needsRouteSelect = contract?.contractKind === "GENERAL" && routes.length > 1; const containerSizes = useMemo(() => { const sizes = new Set(); (contract?.cargoScope ?? []).forEach((s) => { if (s.containerSize) sizes.add(s.containerSize); }); return [...sizes]; }, [contract?.cargoScope]); const bulkCargoOptions = useMemo(() => { const seen = new Map(); (contract?.cargoScope ?? []).forEach((s) => { if (s.containerSize || !s.cargoTypeId) return; if (!seen.has(s.cargoTypeId)) { seen.set(s.cargoTypeId, s.cargoFreeText?.trim() || s.cargoTypeId); } }); return [...seen.entries()].map(([value, label]) => ({ value, label })); }, [contract?.cargoScope]); const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? ""; useEffect(() => { if (!bookingRequest || prefilled) return; setPrefilled(true); const lines = bookingRequest.requestedLines ?? {}; if (lines.containers?.length) { setContainerLines( lines.containers.map((c) => ({ containerSize: c.containerSize, // The request carries counts; pre-toggle the first N units so GL sees // the customer's declared hazardous/reefer split and can adjust it. units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({ ...emptyUnit(), hazardous: i < Number(c.hazardousQuantity ?? 0), reefer: i < Number(c.reeferQuantity ?? 0), })), })), ); } else if (lines.bulk) { setBulkLines([ { cargoTypeId: lines.bulk.cargoTypeId ?? defaultBulkCargoTypeId, cargoWeightTons: lines.bulk.cargoWeightTons ?? "", itemCount: lines.bulk.itemCount ?? "", hazardousQuantity: lines.bulk.hazardousQuantity ?? "0", reeferQuantity: "", }, ]); } if (bookingRequest.contractRouteId) setContractRouteId(bookingRequest.contractRouteId); if (bookingRequest.notes) setNotes(bookingRequest.notes); }, [bookingRequest, prefilled, defaultBulkCargoTypeId]); useEffect(() => { if (!contract || prefilled || seededRef.current) return; seededRef.current = true; if (isContainer && containerSizes.length > 0 && containerLines.length === 0) { setContainerLines( containerSizes.map((size) => ({ containerSize: size, units: [emptyUnit()], })), ); } else if (!isContainer && bulkLines.length === 0) { setBulkLines([ { cargoTypeId: defaultBulkCargoTypeId, cargoWeightTons: "", itemCount: "", hazardousQuantity: "0", reeferQuantity: "0", }, ]); } }, [ contract, prefilled, isContainer, containerSizes, containerLines.length, bulkLines.length, defaultBulkCargoTypeId, ]); const quantities: GlShipmentQuantities = useMemo( () => ({ isContainer: Boolean(isContainer), containers: containerLines.map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, hazardousQuantity: l.units.filter((u) => u.hazardous).length, reeferQuantity: l.units.filter((u) => u.reefer).length, })), bulkQuantity: bulkLines.reduce( (s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0), 0, ), bulkHazardousQuantity: bulkLines.reduce( (s, l) => s + Number(l.hazardousQuantity || 0), 0, ), }), [isContainer, containerLines, bulkLines], ); const priceTotal = useMemo( () => (contract ? computeGlShipmentTotal(contract, quantities) : null), [contract, quantities], ); const selectedRoute = useMemo( () => routes.find((r) => r.id === contractRouteId) ?? routes[0], [routes, contractRouteId], ); const cargoQuery = useMemo(() => { if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId) return null; if (isContainer) { const containers = containerLines .map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, })) .filter((c) => c.quantity >= 1); if (containers.length === 0) return null; return { originYardId: selectedRoute.originYardId, destinationYardId: selectedRoute.destinationYardId, freightType: "CONTAINER", containers, }; } const tons = bulkLines.reduce( (s, l) => s + Number(l.cargoWeightTons || 0), 0, ); if (tons <= 0) return null; return { originYardId: selectedRoute.originYardId, destinationYardId: selectedRoute.destinationYardId, freightType: "BULK", cargoTypeCode: contract?.pricingBreakdown?.lineItems?.find((li) => li.cargoTypeCode) ?.cargoTypeCode ?? undefined, totalWeightTons: tons, }; }, [selectedRoute, isContainer, containerLines, bulkLines, contract?.pricingBreakdown]); const { data: availableDays, isLoading: daysLoading } = useQuery({ ...api.trainScheduling.availableDaysForCargo.queryOptions({ input: cargoQuery ?? { freightType: "BULK" as const }, }), enabled: cargoQuery !== null, }); const syncUnits = (lineIdx: number, qty: number) => { setContainerLines((prev) => prev.map((line, i) => { if (i !== lineIdx) return line; const next = [...line.units]; while (next.length < qty) next.push(emptyUnit()); next.length = Math.max(0, qty); return { ...line, units: next }; }), ); }; const patchLine = (idx: number, patch: Partial) => setContainerLines((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), ); const patchUnit = ( lineIdx: number, unitIdx: number, patch: Partial, ) => patchLine(lineIdx, { units: containerLines[lineIdx].units.map((u, i) => i === unitIdx ? { ...u, ...patch } : u, ), }); const patchBulk = (idx: number, patch: Partial) => setBulkLines((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), ); // Same client-side validation as the customer portal shipment form: ISO // container numbers (unique within the shipment) and a positive VGM per unit; // bulk needs a positive quantity with hazardous/reefer portions bounded by it. const [showErrors, setShowErrors] = useState(false); // Excel import: one row per container. All-or-nothing — a file with any bad // row is rejected with row-numbered errors so nothing is silently dropped. const [importErrors, setImportErrors] = useState([]); const [importSummary, setImportSummary] = useState(null); const importResetRef = useRef<(() => void) | null>(null); const excelOpts = { allowedSizes: containerSizes, includeHazardous: contract?.isHazardous ?? false, includeReefer: contract?.isReefer ?? false, }; const handleImportFile = async (file: File | null) => { // Reset the hidden input so re-picking the same (fixed) file re-fires. importResetRef.current?.(); if (!file) return; const { rows, errors } = await parseContainerExcel(file, excelOpts); if (errors.length > 0) { setImportSummary(null); setImportErrors(errors); return; } // Replace only the lines for sizes present in the file; a contracted size // the file omits keeps whatever was already entered for it. setContainerLines((prev) => containerSizes.map((size) => { const imported = rows.filter((r) => r.containerSize === size); if (imported.length === 0) { return ( prev.find((l) => l.containerSize === size) ?? { containerSize: size, units: [emptyUnit()], } ); } return { containerSize: size, units: imported.map((r) => ({ containerNumber: r.containerNumber, sealNumber: r.sealNumber, vgmTons: r.vgmTons, hazardous: r.hazardous, reefer: r.reefer, })), }; }), ); setImportErrors([]); setShowErrors(false); setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`); }; const unitErrors = useMemo(() => { if (!isContainer) return []; const numberCounts = new Map(); containerLines.forEach((line) => line.units.forEach((u) => { const key = u.containerNumber.trim().toUpperCase(); if (!key) return; numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1); }), ); return containerLines.map((line) => line.units.map((u) => { const errs: UnitErrors = {}; const key = u.containerNumber.trim().toUpperCase(); if (!key) { errs.containerNumber = "Container number is required."; } else if (!ISO_CONTAINER_NUMBER_REGEX.test(key)) { errs.containerNumber = "Enter a valid ISO container number (e.g. ABCD1234567)."; } else if ((numberCounts.get(key) ?? 0) > 1) { errs.containerNumber = "Duplicate container number in this shipment."; } const vgm = Number(u.vgmTons); if (String(u.vgmTons).trim() === "" || Number.isNaN(vgm) || vgm <= 0) { errs.vgmTons = "Enter a valid VGM."; } return errs; }), ); }, [isContainer, containerLines]); const bulkErrors = useMemo(() => { if (isContainer) return []; return bulkLines.map((line) => { const errs: BulkErrors = {}; const qty = Number(line.cargoWeightTons || line.itemCount || 0); if (Number.isNaN(qty) || qty <= 0) { errs.quantity = "Enter a quantity greater than 0."; } const h = Number(line.hazardousQuantity || 0); if (Number.isNaN(h) || h < 0) { errs.hazardous = "Enter a valid hazardous quantity."; } else if (qty > 0 && h > qty) { errs.hazardous = `Can't exceed the cargo quantity (${qty}).`; } const r = Number(line.reeferQuantity || 0); if (Number.isNaN(r) || r < 0) { errs.reefer = "Enter a valid refrigerated quantity."; } else if (qty > 0 && r > qty) { errs.reefer = `Can't exceed the cargo quantity (${qty}).`; } return errs; }); }, [isContainer, bulkLines]); const cargoValid = isContainer ? unitErrors.every((line) => line.every((e) => !e.containerNumber && !e.vgmTons), ) : bulkErrors.every((e) => !e.quantity && !e.hazardous && !e.reefer); const canSubmit = windowOpen && Boolean(scheduledDate) && (!needsRouteSelect || Boolean(contractRouteId)) && (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); /** The create-booking DTO from the current form state — shared by the * authoritative price preview and the actual submit so what GL confirms is * exactly what gets booked. */ const buildPayload = (): Freight.CreateBookingUnderContractDto | null => { if (!scheduledDate || !contract) return null; const payload: Freight.CreateBookingUnderContractDto = { scheduledDate, ...(contractRouteId ? { contractRouteId } : {}), ...(notes.trim() ? { notes: notes.trim() } : {}), // Equipment return is a container concern — bulk keeps the contract default. ...(isContainer ? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" } : {}), }; if (isContainer) { payload.containers = containerLines .filter((l) => l.units.length > 0) .map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, // Counts are derived from the per-unit toggles — they can never // exceed the line quantity. hazardousQuantity: l.units.filter((u) => u.hazardous).length, reeferQuantity: l.units.filter((u) => u.reefer).length, units: l.units.map((u) => ({ containerNumber: u.containerNumber.trim().toUpperCase(), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), vgmTons: Number(u.vgmTons) || 0, })), })); } else { payload.bulkLines = bulkLines.map((l) => ({ ...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}), ...(l.cargoWeightTons !== "" ? { cargoWeightTons: Number(l.cargoWeightTons) } : {}), ...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}), ...(l.hazardousQuantity !== "" ? { hazardousQuantity: Number(l.hazardousQuantity) } : {}), ...(l.reeferQuantity !== "" ? { reeferQuantity: Number(l.reeferQuantity) } : {}), })); } return payload; }; // Authoritative price preview (same pricing pass the booking persists at // create): rail freight + first/last mile + overweight + every surcharge. // Fired when the price modal opens; the modal falls back to the contract // unit-rate estimate while it loads. const validateShipmentMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => contractsService.validateShipment(id ?? "", dto), }); const validation = validateShipmentMutation.data ?? null; const serverTotal = useMemo(() => { const items = validation?.lineItems; if (!items?.length) return null; return { currency: validation?.currency ?? priceTotal?.currency ?? "ETB", lines: items.map((li) => ({ label: li.description, unitPrice: li.unitAmount, unit: li.unit.toLowerCase(), quantity: li.quantity, amount: li.amount, })), total: validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), }; }, [validation, priceTotal]); const displayTotal = serverTotal ?? priceTotal; const pairingErrors = validation?.pairingErrors ?? []; const capacityErrors = validation?.capacityErrors ?? []; const overweightLines = validation?.overweightLines ?? []; const openPriceModal = () => { // Surface the per-field errors (portal-parity validation) instead of // sending an invalid payload to the price preview. if (!cargoValid) { setShowErrors(true); return; } setShowErrors(false); setPriceOpen(true); const payload = buildPayload(); if (payload) { validateShipmentMutation.reset(); validateShipmentMutation.mutate(payload); } }; const handleSubmit = () => { if (!contract || !windowOpen || !cargoValid) return; // Never book past unresolved 20ft pairing hard-blocks. if (pairingErrors.length > 0) return; // A line above the container type's max capacity can never book. if (capacityErrors.length > 0) return; const payload = buildPayload(); if (!payload) return; mutations.createBooking.mutate(payload, { onSuccess: async (booking) => { if (requestId) { try { await contractsService.acceptBookingRequest(requestId, booking.id); } catch { // Non-fatal } } if (contract.contractKind === "GENERAL") { // GENERAL per-booking clearance: land on the booking's clearance // detail — the same page the Shipments tab on the hub opens. navigate(`/dashboard/clearance/${booking.id}`); } else { // ONE_TIME customs keeps its clearance on the contract. navigate(`/dashboard/contracts/clearance/${contract.id}`); } }, }); }; if (isLoading) { return (
); } if (!contract) { return ( ); } const bulkUom = bulkUnitOfMeasure(contract); return ( New Shipment Booking Book a shipment on behalf of the customer for contract {contract.reference}. {bookingRequest ? ( } title="From shipment request" mb="lg" > Booking on behalf of the customer for request{" "} {bookingRequest.reference}. {bookingRequest.scheduledDate ? ( <> {" "} Customer requested{" "} {new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", year: "numeric", }).format(new Date(bookingRequest.scheduledDate))} {" "} — set the binding shipment date below. ) : null} ) : null} {!windowsLoading && !windowOpen ? ( } title="Booking window is closed" mb="lg" > GL can create a booking only while a window is open.{" "} {nextWindow?.windowOpensAt ? ( <> Next window: {fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT{" "} for{" "} {nextWindow.origin ?? "Origin"} → {nextWindow.destination ?? "Destination"} . ) : ( <>No upcoming booking window scheduled. )} ) : null} {windowsLoading || windowOpen ? ( <> } title="Route" description={ needsRouteSelect ? "Choose which contracted route this shipment ships on." : "This shipment ships on the contract's only route." } /> {needsRouteSelect ? ( patchBulk(idx, { cargoTypeId: v ?? "" })} data={bulkCargoOptions} radius={10} styles={fieldStyles} /> ) : null} {bulkUom === "PER_TON" ? ( patchBulk(idx, { cargoWeightTons: v })} radius={10} styles={fieldStyles} /> ) : ( patchBulk(idx, { itemCount: v })} radius={10} styles={fieldStyles} /> )} {contract.isHazardous ? ( patchBulk(idx, { hazardousQuantity: v })} radius={10} styles={fieldStyles} /> ) : null} {contract.isReefer ? ( patchBulk(idx, { reeferQuantity: v })} radius={10} styles={fieldStyles} /> ) : null} ))} )} {isContainer ? ( } title="Equipment Return" description="Choose whether the empty container(s) come back to EDR after unloading." /> setWithReturn((v) => !v)} > With return {withReturn ? "Container(s) returned to EDR after unloading." : "Container(s) retained by the customer after delivery."} setWithReturn(e.currentTarget.checked)} onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0 }} /> ) : null} } title="Schedule" description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected." /> {cargoQuery === null ? ( } > Enter your cargo details first — available shipment days depend on the wagons your cargo needs. ) : ( Shipment day * )}