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, 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, 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 } 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"; /** 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), }); // 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(""); // The customer states the billing currency on their shipment request — GL // books in it. Intercity is always ETB (the API enforces this too). const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD"); // 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); 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"; // 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.paymentCurrency === "USD" || bookingRequest.paymentCurrency === "ETB") { setPaymentCurrency(bookingRequest.paymentCurrency); } 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; 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; const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError; /** 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; }; // 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; 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 submitPending = mutations.createBooking.isPending || mutations.completeBooking.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; 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); } }; 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; 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 ? (