import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ActionIcon, Alert, Badge, Box, Button, Center, Divider, FileButton, Group, Loader, Modal, Paper, Select, Stack, Switch, Text, Textarea, TextInput, ThemeIcon, Title, Tooltip, } from "@mantine/core"; import { AlertCircle, AlertTriangle, CalendarDays, CheckCircle2, ChevronLeft, FileDown, FileText, FileUp, Flame, Link2, MapPin, Package, Receipt, Repeat, Snowflake, X, } from "lucide-react"; import type { Freight } from "@edr/types"; import { CurrencySelector, ExportTrainPicker, OperationDatePicker, } from "@edr/ui-common"; import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; import { contractsService, type ConsolidationCandidate, } from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; import { useContractCapacity, 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"; import { ConsolidationPartnerPanel, emptyPartnerLine, } from "./gl-booking-form/ConsolidationPartnerPanel"; import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; /** * Container sizes offered on the parent-booking panel. Fixed rather than taken * from this contract's scope: the parent booking is a different customer on a * different contract, so its sizes are its own. */ const PARTNER_SIZES = ["20ft", "40ft"]; /** 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}$/; /** * Every quantity on this form is a non-negative magnitude. A native number * input's `min` only constrains its stepper, so swallow the minus key before it * can put a negative into the field at all. Same guard as the portal form. */ const blockNegative = (event: KeyboardEvent) => { if (event.key === "-") event.preventDefault(); }; interface UnitErrors { containerNumber?: string; vgmTons?: string; } interface LineErrors { quantity?: string; hazardousQuantity?: string; reeferQuantity?: string; returnQuantity?: string; units?: 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: string; /** Handling is per physical container; the line counts roll these up. */ isHazardous: boolean; isReefer: boolean; isReturn: boolean; } /** Mirrors the portal shipment form's container line: line-level quantity + * hazardous/reefer counts with per-unit number/seal/VGM details. */ interface ContainerLineDraft { containerSize: string; quantity: string; hazardousQuantity: string; reeferQuantity: string; /** Units of this line shipping with empty-container return (contract WITH_RETURN only). */ returnQuantity: string; units: UnitDraft[]; } interface BulkDraft { cargoWeightTons: string; itemCount: string; hazardousQuantity: string; reeferQuantity: string; } function emptyUnit(): UnitDraft { return { containerNumber: "", sealNumber: "", vgmTons: "", isHazardous: false, isReefer: false, isReturn: false, }; } function emptyLine(size: string): ContainerLineDraft { return { containerSize: size, quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", units: [], }; } 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() { // With `bookingId` the form runs in COMPLETION mode: the bare instance // (auto-initiated by the customer's shipment request) already finished its // per-booking customs clearance, and this form supplies the deferred cargo // (container numbers, VGM) + binding shipment day. Same window gate, same // validation and price confirmation — the submit completes the existing // booking instead of creating a new one. const { id, bookingId: completeBookingId } = useParams<{ id: string; bookingId?: string; }>(); const [searchParams] = useSearchParams(); const requestIdParam = searchParams.get("requestId"); // Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same // contract (GL only picks a new schedule). Set by the clearance Rebook action. const copyFromParam = searchParams.get("copyFrom"); const navigate = useNavigate(); const { data: contract, isLoading } = useContractDetail(id); const mutations = useContractMutations(id ?? ""); // Completion mode without an explicit ?requestId=: find the shipment request // that initiated this instance so the quantities still prefill. const { data: contractRequests } = useQuery({ queryKey: ["shipment-requests-for-contract", id], queryFn: () => contractsService.listBookingRequests(id!), enabled: Boolean(id) && Boolean(completeBookingId) && !requestIdParam, }); const requestId = requestIdParam ?? (completeBookingId ? (contractRequests?.find( (r) => r.createdBookingId === completeBookingId, )?.id ?? null) : null); const { data: bookingRequest } = useQuery({ queryKey: ["shipment-request", requestId], queryFn: () => contractsService.getBookingRequest(requestId!), enabled: Boolean(requestId), }); // The expired booking a Rebook is copying from (its cargo seeds the form). const { data: copyFromBooking } = useQuery({ queryKey: ["rebook-copy-from", copyFromParam], queryFn: () => bookingsService.getById(copyFromParam!), enabled: Boolean(copyFromParam), }); // The booking being completed — used to name the customer on the price // confirmation when a second booking's price is shown beside it. const { data: completeBooking } = useQuery({ queryKey: ["gl-complete-booking", completeBookingId], queryFn: () => bookingsService.getById(completeBookingId!), enabled: Boolean(completeBookingId), }); // Same window-gating the customer sees: booking is only allowed while a // window is OPEN for one of the contract's routes. Intercity contracts are // never window-gated — the shipment rides a passing train staff pick later. const contractId = contract?.id ?? id; const { data: bookingWindows, isLoading: windowsLoading } = useQuery({ ...api.trainScheduling.contractBookingWindows.queryOptions({ input: { contractId: contractId ?? "" }, refetchInterval: 60_000, }), 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 next moment booking OPENS (chronological), which may belong to a // later-departing train. Departure-first ordering here named the soonest // train's later opening as "next" while another lane opened earlier. const nextWindow = useMemo(() => { const now = Date.now(); return (bookingWindows ?? []) .filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now) .sort( (a, b) => new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(), )[0]; }, [bookingWindows]); const [scheduledDate, setScheduledDate] = useState(""); // EXPORT rail completion: the specific train GL picks for the shipment day. const [trainScheduleId, setTrainScheduleId] = useState(""); const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); // ponytail: ETB-only for now — widen back to "USD" | "ETB" when multi-currency billing returns. const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("ETB"); // What the containers carry — captured per booking (moved off the contract). const [cargoDescription, setCargoDescription] = useState(""); const [containerLines, setContainerLines] = useState([]); const [bulk, setBulk] = useState({ cargoWeightTons: "", itemCount: "", hazardousQuantity: "0", reeferQuantity: "0", }); const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); // ── Odd-20ft shared wagon (customs / Path B) ────────────────────────────── // An odd 20ft total leaves one container unpaired. On a customs contract GL // resolves that here by linking a second booking that is also odd — two odd // counts always sum to even — completing both together onto the shared wagon. const [consolidateOdd, setConsolidateOdd] = useState(false); // Set once GL flips the toggle by hand, so the auto-on effect below never // re-opens a panel GL deliberately closed. const consolidateTouchedRef = useRef(false); const [partnerPickerOpen, setPartnerPickerOpen] = useState(false); const [partner, setPartner] = useState(null); const [partnerLines, setPartnerLines] = useState([]); const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); 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"; // The contract gates the empty-container return service — like hazardous. // WITH_RETURN contracts capture a per-line return quantity instead of the // legacy booking-level toggle; other contracts cannot switch it on. const contractWithReturn = isContainer && contract?.equipmentReturn === "WITH_RETURN"; // Legacy contracts (no equipment return chosen at creation) keep the old // booking-level toggle. const legacyReturnToggle = isContainer && !contract?.equipmentReturn; /** * Handling switches offered on each container row — only the services this * contract was created with, since the server rejects the others. */ const handlingColumns = ( [ contract?.isHazardous && { key: "isHazardous", label: "Hazardous", icon: , color: "#C0392B", }, contract?.isReefer && { key: "isReefer", label: "Refrigerated", icon: , color: "#2E5B96", }, contractWithReturn && { key: "isReturn", label: "With return", icon: , color: "#0A6F4D", }, ] as Array< | false | undefined | { key: keyof UnitDraft; label: string; icon: ReactNode; color: string } > ).filter(Boolean) as Array<{ key: "isHazardous" | "isReefer" | "isReturn"; label: string; icon: ReactNode; color: string; }>; // Intercity shipments ride a passing import/export train staff pick at // finalize time — no shipment day is chosen and no window gate applies. const isIntercity = contract?.tradeDirection === "DOMESTIC"; // USD billing is offered on import traffic only — export and domestic // shipments are always invoiced in ETB. const isImport = contract?.tradeDirection === "IMPORT"; // ONE_TIME split-remainder mode: a previous booking on this contract was // split on train capacity, so the capacity endpoint reports the outstanding // remainder — the EXACT quantity this booking must take (API-enforced). const { data: splitCapacityLines = [] } = useContractCapacity( contract?.contractKind === "ONE_TIME" ? contract?.id : undefined, ); const remainderLines = useMemo( () => contract?.contractKind === "ONE_TIME" ? splitCapacityLines.filter((l) => (l.remaining ?? 0) > 0) : [], [contract?.contractKind, splitCapacityLines], ); const remainderMode = remainderLines.length > 0; // Bulk: prefill the exact outstanding remainder once — a different amount // would be rejected at create anyway. const remainderSeededRef = useRef(false); useEffect(() => { if (!remainderMode || isContainer || remainderSeededRef.current) return; remainderSeededRef.current = true; setBulk((b) => b.cargoWeightTons || b.itemCount ? b : { ...b, cargoWeightTons: String(remainderLines[0].remaining) }, ); }, [remainderMode, isContainer, remainderLines]); const remainderNotice = remainderMode ? ( } title="This booking must take the whole split remainder" > The previous booking on this one-time contract was split — the paid part is shipping and the rest returned to the contract. This booking must cover exactly the remaining{" "} {remainderLines .map((l) => isContainer ? `${l.remaining} × ${l.containerSize ?? "container"}` : `${l.remaining} tons`, ) .join(", ")} . A different quantity will be rejected, and no other booking can be created on this contract. ) : null; const routes = useMemo( () => [...(contract?.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder), [contract?.routes], ); const multiRoute = 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]); useEffect(() => { if (!bookingRequest || prefilled) return; // A rebook (?copyFrom=) seeds from the expired booking's real cargo — // richer than the request's bare quantities. Let that seed win the race. if (copyFromParam) return; setPrefilled(true); const lines = bookingRequest.requestedLines ?? {}; if (lines.containers?.length) { setContainerLines( lines.containers.map((c) => ({ containerSize: c.containerSize, quantity: String(Math.max(1, c.quantity)), hazardousQuantity: String(c.hazardousQuantity ?? 0), reeferQuantity: String(c.reeferQuantity ?? 0), returnQuantity: "0", units: Array.from({ length: Math.max(1, c.quantity) }, emptyUnit), })), ); } else if (lines.bulk) { setBulk({ cargoWeightTons: lines.bulk.cargoWeightTons != null ? String(lines.bulk.cargoWeightTons) : "", itemCount: lines.bulk.itemCount != null ? String(lines.bulk.itemCount) : "", hazardousQuantity: String(lines.bulk.hazardousQuantity ?? 0), reeferQuantity: "0", }); } if (bookingRequest.contractRouteId) setContractRouteId(bookingRequest.contractRouteId); if (bookingRequest.notes) setNotes(bookingRequest.notes); }, [bookingRequest, prefilled]); // Rebook seed: copy the source booking's container lines once. (Bulk weight / // item count isn't on the booking payload yet, so bulk rebooks fall through to // the normal contract seed and GL re-enters the quantity.) useEffect(() => { if (!copyFromBooking || prefilled) return; const lines = copyFromBooking.bookingContainers ?? []; if (!lines.length) return; // The booking stores a numeric sizeFt (20) but the contract scope — and the // create payload the server validates — uses its own size strings ("20ft"). // Seed with the scope's string so the rebook payload matches what a fresh // form entry would send. const scopeSizeForFt = (sizeFt: number | null | undefined): string => { if (sizeFt == null) return ""; return ( containerSizes.find((s) => parseInt(s, 10) === Number(sizeFt)) ?? `${sizeFt}ft` ); }; setPrefilled(true); // Rebook carries the expired booking's cargo description forward. if (copyFromBooking.cargoFreeText) { setCargoDescription(copyFromBooking.cargoFreeText); } setContainerLines( lines.map((c) => { const qty = Math.max(1, c.quantity); // Carry the persisted per-unit details (numbers, seals, VGM, handling) // when the source booking has them — a rebooked EXPIRED booking does, // and its cargo is fixed server-side anyway. const units: UnitDraft[] = c.units?.length === qty ? c.units.map((u) => ({ containerNumber: u.containerNumber ?? "", sealNumber: u.sealNumber ?? "", vgmTons: u.vgmTons != null ? String(u.vgmTons) : "", isHazardous: Boolean(u.isHazardous), isReefer: Boolean(u.isReefer), isReturn: Boolean(u.isReturn), })) : Array.from({ length: qty }, emptyUnit); return { containerSize: scopeSizeForFt(c.containerType?.sizeFt), quantity: String(qty), hazardousQuantity: String(units.filter((u) => u.isHazardous).length), reeferQuantity: String(units.filter((u) => u.isReefer).length), returnQuantity: String(units.filter((u) => u.isReturn).length), units, }; }), ); }, [copyFromBooking, prefilled, containerSizes]); // Seed one shipment line per contracted size exactly once — same seeding the // portal form does. Subsequent renders reuse the lines. useEffect(() => { if (!contract || prefilled || seededRef.current) return; seededRef.current = true; if (isContainer && containerSizes.length > 0 && containerLines.length === 0) { setContainerLines(containerSizes.map(emptyLine)); } }, [contract, prefilled, isContainer, containerSizes, containerLines.length]); const quantities: GlShipmentQuantities = useMemo( () => ({ isContainer: Boolean(isContainer), containers: containerLines.map((l) => ({ containerSize: l.containerSize, quantity: Number(l.quantity || 0), hazardousQuantity: Number(l.hazardousQuantity || 0), reeferQuantity: Number(l.reeferQuantity || 0), returnQuantity: Number(l.returnQuantity || 0), })), bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0), bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0), bulkReeferQuantity: Number(bulk.reeferQuantity || 0), }), [isContainer, containerLines, bulk], ); const priceTotal = useMemo( () => (contract ? computeGlShipmentTotal(contract, quantities) : null), [contract, quantities], ); const selectedRoute = useMemo( () => routes.find((r) => r.id === contractRouteId) ?? routes[0], [routes, contractRouteId], ); // Read the cargo entered above so the day list reflects what can actually be // shipped (matching wagons + open train capacity) — portal parity. const cargoQuery = useMemo(() => { if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId) return null; if (isContainer) { const containers = containerLines .map((l) => ({ containerSize: l.containerSize, quantity: Number(l.quantity || 0), })) .filter((c) => c.quantity >= 1); if (containers.length === 0) return null; return { originYardId: selectedRoute.originYardId, destinationYardId: selectedRoute.destinationYardId, freightType: "CONTAINER", containers, }; } const tons = Number(bulk.cargoWeightTons || 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, bulk.cargoWeightTons, contract?.pricingBreakdown]); const { data: availableDays, isLoading: daysLoading } = useQuery({ ...api.trainScheduling.availableDaysForCargo.queryOptions({ input: cargoQuery ?? { freightType: "BULK" as const }, }), enabled: cargoQuery !== null && !isIntercity, }); // EXPORT completes pick the TRAIN, not just the day (portal parity). Only // when completing an initiated instance — a fresh GL create goes through // clearance and picks its train there. const isExportPick = contract?.tradeDirection === "EXPORT" && Boolean(completeBookingId); const wagonsEstimate = useMemo(() => { if (!isContainer) return undefined; const ft20 = containerLines .filter((l) => parseInt(l.containerSize, 10) === 20) .reduce((s, l) => s + Number(l.quantity || 0), 0); const ft40 = containerLines .filter((l) => parseInt(l.containerSize, 10) === 40) .reduce((s, l) => s + Number(l.quantity || 0), 0); const wagons = Math.ceil(ft20 / 2) + ft40; return wagons > 0 ? wagons : undefined; }, [isContainer, containerLines]); const exportTrainsQuery = useQuery({ ...api.trainScheduling.exportTrains.queryOptions({ input: { bookingId: completeBookingId ?? "", date: scheduledDate, cargo: { containerSizes: isContainer ? containerLines .filter((l) => Number(l.quantity || 0) >= 1) .map((l) => l.containerSize) : undefined, cargoTypeCode: !isContainer ? (contract?.pricingBreakdown?.lineItems?.find( (li) => li.cargoTypeCode, )?.cargoTypeCode ?? undefined) : undefined, wagons: wagonsEstimate, }, }, }), enabled: isExportPick && Boolean(scheduledDate), }); /** * Line handling totals are a roll-up of the per-container switches — the * count is however many containers ticked each service. Recomputed on every * unit change so the price estimate and payload follow the switches. */ const withDerivedCounts = (line: ContainerLineDraft): ContainerLineDraft => ({ ...line, hazardousQuantity: String(line.units.filter((u) => u.isHazardous).length), reeferQuantity: String(line.units.filter((u) => u.isReefer).length), returnQuantity: String(line.units.filter((u) => u.isReturn).length), }); // Keep the units array length in sync with the entered quantity. 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 withDerivedCounts({ ...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, ) => setContainerLines((prev) => prev.map((l, i) => i === lineIdx ? withDerivedCounts({ ...l, units: l.units.map((u, j) => (j === unitIdx ? { ...u, ...patch } : u)), }) : l, ), ); // Drop one container row and shrink quantity to match — the inverse of // syncUnits growing the array when quantity goes up. const removeUnit = (lineIdx: number, unitIdx: number) => setContainerLines((prev) => prev.map((l, i) => { if (i !== lineIdx) return l; const units = l.units.filter((_, j) => j !== unitIdx); return withDerivedCounts({ ...l, quantity: String(units.length), units }); }), ); // Same client-side validation as the customer portal shipment form // (new-shipment-form/schema.ts): ISO container numbers unique within the // shipment, positive VGM per unit, hazardous/reefer counts bounded by the // line quantity; bulk needs a positive quantity with hazardous/reefer // portions bounded by it. Errors only show after a submit attempt. 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, includeReturn: contractWithReturn, }; 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) ?? emptyLine(size); } return { containerSize: size, quantity: String(imported.length), hazardousQuantity: String(imported.filter((r) => r.hazardous).length), reeferQuantity: String(imported.filter((r) => r.reefer).length), returnQuantity: String(imported.filter((r) => r.withReturn).length), // The spreadsheet marks handling per row — carry it onto the // container it belongs to rather than collapsing it to a line count. units: imported.map((r) => ({ containerNumber: r.containerNumber, sealNumber: r.sealNumber, vgmTons: String(r.vgmTons), isHazardous: Boolean(r.hazardous), isReefer: Boolean(r.reefer), isReturn: Boolean(r.withReturn), })), }; }), ); 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 (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) { errs.vgmTons = "Enter a valid VGM."; } return errs; }), ); }, [isContainer, containerLines]); const lineErrors = useMemo(() => { if (!isContainer || !contract) return []; // A line can be 0 (the contract covers both sizes; a booking may only need // one) but the booking as a whole needs at least one container — anchor // that error on the first line's quantity so it renders in the field. const totalQty = containerLines.reduce( (sum, l) => sum + Math.max(0, Number(l.quantity) || 0), 0, ); return containerLines.map((line, idx) => { const errs: LineErrors = {}; const qty = Number(line.quantity || 0); if (line.quantity.trim() === "") { errs.quantity = "Quantity is required."; } else if (Number.isNaN(qty) || qty < 0) { errs.quantity = "Enter 0 or more."; } else if (idx === 0 && totalQty < 1) { errs.quantity = "Book at least one container (either size)."; } else if (qty >= 1 && line.units.length < qty) { errs.units = `Enter details for all ${qty} container(s).`; } if (contract.isHazardous) { const h = Number(line.hazardousQuantity || 0); if (Number.isNaN(h) || h < 0) { errs.hazardousQuantity = "Enter a valid hazardous quantity."; } else if (h > qty) { errs.hazardousQuantity = `Can't exceed the ${qty} container(s) in this line.`; } } if (contract.isReefer) { const r = Number(line.reeferQuantity || 0); if (Number.isNaN(r) || r < 0) { errs.reeferQuantity = "Enter a valid refrigerated quantity."; } else if (r > qty) { errs.reeferQuantity = `Can't exceed the ${qty} container(s) in this line.`; } } if (contractWithReturn) { const w = Number(line.returnQuantity || 0); if (Number.isNaN(w) || w < 0) { errs.returnQuantity = "Enter a valid return quantity."; } else if (w > qty) { errs.returnQuantity = `Can't exceed the ${qty} container(s) in this line.`; } } return errs; }); }, [isContainer, contract, containerLines, contractWithReturn]); // 20ft containers ride two per wagon, so an odd total leaves one unpaired and // the booking can never be planned. The server rejects it too (the price // modal's `pairingErrors`), but that only lands after GL has filled the whole // form — mirror the customer portal (new-booking-form/schema.ts `calcWagons`) // and block it inline instead. Size strings arrive as "20ft" from the contract // scope but as a bare "20" from the rebook seed, so match on the leading digits. const ft20Total = useMemo(() => { if (!isContainer) return 0; return containerLines .filter((l) => parseInt(l.containerSize, 10) === 20) .reduce((sum, l) => sum + Number(l.quantity || 0), 0); }, [isContainer, containerLines]); const hasOdd20ft = ft20Total % 2 === 1; // Only a customs (Path B) instance being COMPLETED by GL can use the shared // wagon: it is GL, not the customer, who links the two bookings. Anything else // keeps the historical hard block on odd 20ft. const oddConsolidationAvailable = Boolean( completeBookingId && isContainer && contract?.customsClearingEnabled, ); // Auto-on: entering an odd 20ft total opens the consolidation panel by itself, // once. GL can still switch it off — then odd is blocked exactly as before. useEffect(() => { if (!oddConsolidationAvailable) return; if (consolidateTouchedRef.current) return; if (hasOdd20ft) setConsolidateOdd(true); }, [oddConsolidationAvailable, hasOdd20ft]); // Clear the partner as soon as the panel closes or stops applying, so a // leftover selection can never ride along into a plain single-booking submit. useEffect(() => { if (consolidateOdd && oddConsolidationAvailable) return; setPartner(null); setPartnerLines([]); setPartnerCargoDescription(""); }, [consolidateOdd, oddConsolidationAvailable]); const consolidationActive = oddConsolidationAvailable && consolidateOdd && hasOdd20ft; // Once a parent booking is linked, each booking's cargo is entered under its // own labelled heading so it is clear which containers belong to whom. const splitView = Boolean(consolidationActive && partner); const candidatesQuery = useQuery({ queryKey: ["consolidation-candidates", id, completeBookingId], queryFn: () => contractsService.listConsolidationCandidates( id ?? "", completeBookingId ?? "", ), enabled: partnerPickerOpen && Boolean(id) && Boolean(completeBookingId), }); const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON"; const bulkErrors = useMemo(() => { if (isContainer) return {}; const errs: BulkErrors = {}; const qty = bulkUom === "PER_ITEM" ? Number(bulk.itemCount || 0) : Number(bulk.cargoWeightTons || 0); if (Number.isNaN(qty) || qty <= 0) { errs.quantity = "Enter a quantity greater than 0."; } const h = Number(bulk.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(bulk.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, bulk, bulkUom]); const dateError = !isIntercity && !scheduledDate ? "Select a shipment date." : undefined; const routeError = multiRoute && !contractRouteId ? "Select a route." : undefined; const cargoDescriptionError = isContainer && !cargoDescription.trim() ? "Describe the cargo carried in the containers." : undefined; const cargoValid = isContainer ? lineErrors.every( (e) => !e.quantity && !e.units && !e.hazardousQuantity && !e.reeferQuantity && !e.returnQuantity, ) && unitErrors.every((line) => line.every((e) => !e.containerNumber && !e.vgmTons), ) && !cargoDescriptionError : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; // The unpaired 20ft container is resolved by the shared wagon, so with an // active consolidation an odd total stops being a blocker; without one it // blocks exactly as before. const oddBlocksSubmit = hasOdd20ft && !consolidationActive; // Partner side: a linked partner must be picked, carry an odd 20ft count of // its own (odd + odd = even fills the wagon) and have complete unit details. const partnerFt20Total = useMemo(() => { if (!consolidationActive) return 0; return partnerLines .filter((l) => parseInt(l.containerSize, 10) === 20) .reduce((sum, l) => sum + Number(l.quantity || 0), 0); }, [consolidationActive, partnerLines]); const partnerError = useMemo(() => { if (!consolidationActive) return undefined; if (!partner) return "Select the booking that shares this wagon."; const totalQty = partnerLines.reduce( (sum, l) => sum + Math.max(0, Number(l.quantity) || 0), 0, ); if (totalQty < 1) { return `Enter the containers for ${partner.reference}.`; } if (partnerFt20Total % 2 === 0) { return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`; } const incomplete = partnerLines.some((line) => { const qty = Number(line.quantity || 0); return qty >= 1 && line.units.length < qty; }); if (incomplete) { return `Enter the container details for all of ${partner.reference}'s containers.`; } const badUnit = partnerLines.some((line) => line.units.some( (u) => !ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) || !(Number(u.vgmTons) > 0), ), ); if (badUnit) { return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`; } if (!partnerCargoDescription.trim()) { return `Describe the cargo carried in ${partner.reference}'s containers.`; } return undefined; }, [ consolidationActive, partner, partnerLines, partnerFt20Total, partnerCargoDescription, ]); const formValid = cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError; /** 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. Mirrors the portal form's buildDto. */ const buildPayload = (): Freight.CreateBookingUnderContractDto | null => { if (!contract) return null; const payload: Freight.CreateBookingUnderContractDto = { ...(contractRouteId ? { contractRouteId } : {}), paymentCurrency, // Intercity bookings carry no date — staff assign a passing train later. ...(scheduledDate ? { scheduledDate: new Date(scheduledDate).toISOString() } : {}), // EXPORT rail: lock the booking onto the picked train. ...(trainScheduleId ? { trainScheduleId } : {}), ...(notes.trim() ? { notes: notes.trim() } : {}), // Equipment return: WITH_RETURN contracts derive it server-side from the // per-line return quantities; only legacy contracts (no value chosen at // creation) still send the booking-level toggle. Bulk keeps the default. ...(legacyReturnToggle ? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" } : {}), }; if (isContainer) { // What the containers carry — captured per booking, not on the contract. if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim(); payload.containers = containerLines .filter((l) => Number(l.quantity) >= 1) .map((l) => ({ containerSize: l.containerSize, quantity: Number(l.quantity), hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined, reeferQuantity: Number(l.reeferQuantity || 0) || undefined, ...(contractWithReturn ? { returnQuantity: Number(l.returnQuantity || 0) } : {}), units: l.units.map((u) => ({ containerNumber: u.containerNumber.trim().toUpperCase(), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), vgmTons: Number(u.vgmTons) || 0, // Per-container handling — the server rolls these into the line // counts and bills each surcharge on the ticked containers only. isHazardous: Boolean(u.isHazardous), isReefer: Boolean(u.isReefer), ...(contractWithReturn ? { isReturn: Boolean(u.isReturn) } : {}), })), })); } else { payload.bulkLines = [ { cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null, ...(bulk.cargoWeightTons !== "" ? { cargoWeightTons: Number(bulk.cargoWeightTons) } : {}), ...(bulk.itemCount !== "" ? { itemCount: Number(bulk.itemCount) } : {}), hazardousQuantity: Number(bulk.hazardousQuantity || 0) || undefined, reeferQuantity: Number(bulk.reeferQuantity || 0) || undefined, }, ]; } return payload; }; /** * Completion DTO for the partner half of a shared wagon. Route, day and train * are deliberately copied from THIS booking: the two bookings ride the same * wagon, so they must ride the same train on the same day. Only the cargo and * the billing currency belong to the partner. */ const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => { if (!partner || !consolidationActive) return null; const payload: Freight.CreateBookingUnderContractDto = { paymentCurrency, ...(scheduledDate ? { scheduledDate: new Date(scheduledDate).toISOString() } : {}), ...(trainScheduleId ? { trainScheduleId } : {}), ...(partnerCargoDescription.trim() ? { cargoFreeText: partnerCargoDescription.trim() } : {}), containers: partnerLines .filter((l) => Number(l.quantity) >= 1) .map((l) => ({ containerSize: l.containerSize, quantity: Number(l.quantity), hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined, reeferQuantity: Number(l.reeferQuantity || 0) || undefined, units: l.units.map((u) => ({ containerNumber: u.containerNumber.trim().toUpperCase(), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), vgmTons: Number(u.vgmTons) || 0, isHazardous: Boolean(u.isHazardous), isReefer: Boolean(u.isReefer), })), })), }; return payload; }; // Authoritative price preview (same pricing pass the booking persists at // create): rail freight + first/last mile + overweight + every surcharge, // plus the hard-block checks (20ft pairing, max capacity, container numbers // already booked on the same train). 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, completeBookingId), }); const validation = validateShipmentMutation.data ?? null; // The partner is priced against ITS OWN contract, so the two totals shown in // the confirm modal are each customer's real bill — nobody pays for the other. const validatePartnerMutation = useMutation({ mutationFn: (input: { contractId: string; bookingId: string; dto: Freight.CreateBookingUnderContractDto; }) => contractsService.validateShipment( input.contractId, input.dto, input.bookingId, ), }); const partnerValidation = validatePartnerMutation.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 overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0; const pairingErrors = validation?.pairingErrors ?? []; const capacityErrors = validation?.capacityErrors ?? []; const containerClashErrors = validation?.containerClashErrors ?? []; const spaceErrors = validation?.spaceErrors ?? []; const overweightLines = validation?.overweightLines ?? []; // Fallback while the server preview loads: the contract's frozen unit rates // with the overweight surcharge folded in — same fallback the portal shows. const displayTotal = useMemo(() => { if (serverTotal) return serverTotal; if (!priceTotal) return null; if (!(overweightSurchargeAmount > 0)) return priceTotal; return { ...priceTotal, lines: [ ...priceTotal.lines, { label: "Overweight surcharge", unitPrice: overweightSurchargeAmount, unit: "flat" as const, quantity: 1, amount: overweightSurchargeAmount, }, ], total: priceTotal.total + overweightSurchargeAmount, }; }, [serverTotal, priceTotal, overweightSurchargeAmount]); const partnerTotal = useMemo(() => { const items = partnerValidation?.lineItems; if (!items?.length) return null; return { currency: partnerValidation?.currency ?? "ETB", lines: items.map((li) => ({ label: li.description, unitPrice: li.unitAmount, unit: li.unit.toLowerCase(), quantity: li.quantity, amount: li.amount, })), total: partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), }; }, [partnerValidation]); // The partner half must clear the same hard blocks as this one — the pair is // booked all-or-nothing, so a block on either side blocks both. const partnerBlockers = useMemo(() => { if (!consolidationActive || !partnerValidation) return []; return [ ...(partnerValidation.pairingErrors ?? []), ...(partnerValidation.capacityErrors ?? []), ...(partnerValidation.containerClashErrors ?? []), ...(partnerValidation.spaceErrors ?? []), ]; }, [consolidationActive, partnerValidation]); const completePairMutation = useMutation({ mutationFn: (input: { payload: Freight.CreateBookingUnderContractDto; partnerPayload: Freight.CreateBookingUnderContractDto; partnerBookingId: string; }) => contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", { partnerBookingId: input.partnerBookingId, booking: input.payload, partner: input.partnerPayload, }), }); const submitPending = mutations.createBooking.isPending || mutations.completeBooking.isPending || completePairMutation.isPending; // Block confirm until the authoritative server price is in hand — the client // estimate is display-only; booking on it would confirm an un-validated, // possibly wrong price. Same guard as the portal form. const confirmDisabled = submitPending || validateShipmentMutation.isPending || pairingErrors.length > 0 || capacityErrors.length > 0 || containerClashErrors.length > 0 || spaceErrors.length > 0 || !serverTotal || // Same bar for the shared-wagon partner: its authoritative price must be in // hand and its own hard blocks clear before either booking is confirmed. (consolidationActive && (validatePartnerMutation.isPending || !partnerTotal || partnerBlockers.length > 0)); const openPriceModal = () => { // Surface the per-field errors (portal-parity validation) instead of // sending an invalid payload to the price preview. if (!formValid) { setShowErrors(true); return; } setShowErrors(false); setPriceOpen(true); const payload = buildPayload(); if (payload) { validateShipmentMutation.reset(); validateShipmentMutation.mutate(payload); } validatePartnerMutation.reset(); const partnerPayload = buildPartnerPayload(); if (partnerPayload && partner?.contractId) { validatePartnerMutation.mutate({ contractId: partner.contractId, bookingId: partner.id, dto: partnerPayload, }); } }; const handleSubmit = () => { if (!contract || !formValid) 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; // A container already on another booking of the same train can never book. if (containerClashErrors.length > 0) return; // An export booking no open train can carry whole can never book. if (spaceErrors.length > 0) return; const payload = buildPayload(); if (!payload) return; // Shared wagon: both halves complete together, all-or-nothing on the server. if (consolidationActive && partner && completeBookingId) { // A hard block on the partner's own price preview blocks the pair. if (partnerBlockers.length > 0) return; const partnerPayload = buildPartnerPayload(); if (!partnerPayload) return; completePairMutation.mutate( { payload, partnerPayload, partnerBookingId: partner.id, }, { onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`), }, ); return; } if (completeBookingId) { // Completion mode: cargo + day land on the already-cleared instance — // the request was linked and accepted at submission time. mutations.completeBooking.mutate( { bookingId: completeBookingId, payload }, { onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`), }, ); return; } mutations.createBooking.mutate(payload, { onSuccess: async (booking) => { if (requestId) { try { await contractsService.acceptBookingRequest(requestId, booking.id); } catch { // Non-fatal } } // Clearance is always per booking — land on that booking's clearance // detail, the same page the hub opens. navigate(`/dashboard/clearance/${booking.id}`); }, }); }; if (isLoading || windowsLoading) { return (
); } if (!contract) { return ( ); } const header = ( {completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"} {completeBookingId ? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.` : `Book a shipment on behalf of the customer for contract ${contract.reference}.`} ); // Coarse gate — same closed-state notice the customer sees on the portal. // Intercity contracts are never window-gated. if (!isIntercity && !windowOpen) { return ( {header} } title="Booking is not open right now" > {nextWindow?.windowOpensAt ? ( <> Next window: {fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT{" "} for{" "} {nextWindow.origin ?? "Origin"} →{" "} {nextWindow.destination ?? "Destination"} . ) : ( <>No upcoming booking window scheduled. )} Come back when the booking window opens to book this shipment. ); } return ( {header} {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} } title="Route" description={ multiRoute ? "Choose which contracted route this shipment ships on." : "This shipment ships on the contract's only route." } /> {multiRoute ? (