import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams, useSearchParams, } from "react-router-dom"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, Box, Button, Center, Divider, Group, Loader, Modal, NumberInput, Paper, Select, Stack, Text, Textarea, TextInput, ThemeIcon, } from "@mantine/core"; import { AlertCircle, AlertTriangle, CalendarDays, CheckCircle2, ChevronLeft, FileText, MapPin, Package, Receipt, 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 { 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"; 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; } interface ContainerLineDraft { containerSize: string; hazardousQuantity: number | string; reeferQuantity: number | 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: "" }; } 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], ); // Soonest future window across all routes, used for the "next window" notice. 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(""); const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); const [containerLines, setContainerLines] = useState([]); const [bulkLines, setBulkLines] = useState([]); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); const seededRef = useRef(false); 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, hazardousQuantity: c.hazardousQuantity ?? "0", reeferQuantity: c.reeferQuantity ?? "", units: Array.from({ length: Math.max(1, c.quantity) }, () => emptyUnit(), ), })), ); } 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, hazardousQuantity: "0", reeferQuantity: "0", 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: Number(l.hazardousQuantity || 0), reeferQuantity: Number(l.reeferQuantity || 0), })), 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)), ); 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() } : {}), }; if (isContainer) { payload.containers = containerLines .filter((l) => l.units.length > 0) .map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, ...(l.hazardousQuantity !== "" ? { hazardousQuantity: Number(l.hazardousQuantity) } : {}), ...(l.reeferQuantity !== "" ? { reeferQuantity: Number(l.reeferQuantity) } : {}), units: l.units.map((u) => ({ containerNumber: u.containerNumber, ...(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 = () => { setPriceOpen(true); const payload = buildPayload(); if (payload) { validateShipmentMutation.reset(); validateShipmentMutation.mutate(payload); } }; const handleSubmit = () => { if (!contract || !windowOpen) 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 } navigate(`/dashboard/bookings/${booking.id}/clearance`); } else { 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} ))} )} } 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 * )}