import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, } from "react"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router-dom"; import { ActionIcon, Alert, Badge, Box, Button, Center, Divider, FileButton, Group, List, Loader, Modal, Paper, Select, Stack, Switch, Text, TextInput, Textarea, ThemeIcon, Title, } from "@mantine/core"; import { AlertCircle, AlertTriangle, CalendarDays, CheckCircle2, ChevronLeft, FileDown, FileUp, Flame, MapPin, Package, PackageCheck, Receipt, Snowflake, Train, X, } from "lucide-react"; import { OperationDatePicker } from "@edr/ui-common"; import { StepCard, StepHeader, StepLabel, fieldStyles, } from "../contracts/new-contract-form/shared"; import { formatRateUnit } from "../contracts/new-contract-form/unit-rates"; import { ShipmentFormInputValues, ShipmentFormValues, createShipmentFormSchema, initialShipmentFormValues, } from "../contracts/new-shipment-form/schema"; import { downloadContainerImportTemplate, parseContainerExcel, } from "../contracts/new-shipment-form/container-excel"; import { formatAmount } from "../contracts/new-shipment-form/total"; import { shippingLineBookingsService, type CompleteBookingContainerLine, type CompleteShippingLineBookingPayload, type ShippingLineBooking, type ShippingLinePriceQuote, } from "@/services/shipping-line-bookings.service"; import { extractApiError } from "@/utils/result"; type ShipmentForm = ReturnType< typeof useForm >; const SIZES = ["20ft", "40ft"] as const; /** * 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. */ const blockNegative = (event: KeyboardEvent) => { if (event.key === "-") event.preventDefault(); }; const COMPLETABLE_STATUSES = new Set([ "CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED", ]); /** * Shipping-line booking completion page — a faithful duplicate of the customer * shipment-booking page (contracts/NewShipmentPage): the same Excel * import/template flow, the same per-container rows with hazardous/refrigerated * switches, the same day-picker calendar. Only the plumbing differs: no * contract — cargo posts to the shipping-line completion endpoint, days come * from the line's dedicated trains (or the shared pool when it has none), and * the price lands on the credit ledger instead of an invoice, so there is no * price-confirmation step. */ export default function ShippingLineCompletePage() { const { id = "" } = useParams(); const navigate = useNavigate(); const bookingQuery = useQuery({ queryKey: ["shipping-line-bookings", id], queryFn: () => shippingLineBookingsService.getById(id), enabled: Boolean(id), }); if (bookingQuery.isLoading) { return (
); } const booking = bookingQuery.data; if (!booking) { return ( Booking not found ); } if (!COMPLETABLE_STATUSES.has(booking.status as string)) { return ( } radius="md"> This booking cannot be completed yet Your documents must be approved by Operations before the cargo and shipment day can be entered. ); } return ; } function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) { const navigate = useNavigate(); const queryClient = useQueryClient(); const isContainer = (booking.freightType ?? "CONTAINER") === "CONTAINER"; const isResubmit = booking.status === "OPERATION_CHANGES_REQUESTED"; // Bulk cargo type lives outside the shared schema (customers get it from // their contract) — validated at submit time instead. const [cargoTypeId, setCargoTypeId] = useState(null); const form = useForm({ defaultValues: { ...initialShipmentFormValues, // Shipping lines are always invoiced in ETB — no currency choice. paymentCurrency: "ETB", }, resolver: zodResolver( createShipmentFormSchema({ isContainer, // Both handling services are always offered — there is no contract // scope to restrict them; charges apply only to ticked containers. isHazardous: true, isReefer: true, withReturnService: false, unitOfMeasure: "PER_TON", requiresDate: true, requiresTrain: false, }), ), mode: "onChange", }); const referenceQuery = useQuery({ queryKey: ["shipping-line-reference-data"], queryFn: shippingLineBookingsService.referenceData, }); // 20ft containers ride two per wagon — an odd total can never be planned. const watchedContainers = form.watch("containers"); const ft20Total = isContainer ? (watchedContainers ?? []) .filter((l) => l.containerSize === "20ft") .reduce((sum, l) => sum + Number(l.quantity || 0), 0) : 0; const hasOdd20ft = ft20Total % 2 === 1; // Two-step submit, same shape as the customer shipment form: the confirm // modal opens at once with the payload pending, the server prices it (and // runs the 20ft pairing check) while the modal shows a loader, the shipping // line confirms the figure, and only then does the booking submit. const [pendingPayload, setPendingPayload] = useState(null); const previewMutation = useMutation({ mutationFn: (payload: CompleteShippingLineBookingPayload) => shippingLineBookingsService.pricePreview(booking.id, payload), // A pricing failure (no rate configured) closes the confirm dialog — the // error modal takes over with the server's message. onError: () => setPendingPayload(null), }); const quote = previewMutation.data ?? null; const submitMutation = useMutation({ mutationFn: (payload: CompleteShippingLineBookingPayload) => shippingLineBookingsService.complete(booking.id, payload), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["shipping-line-bookings"], }); navigate(`/shipping-line/bookings/${booking.id}`); }, // A submit failure (day filled up, window closed meanwhile) must not leave // a stale confirm dialog on screen — the error modal takes over. onError: () => { previewMutation.reset(); setPendingPayload(null); }, }); const closeConfirm = () => { if (submitMutation.isPending) return; previewMutation.reset(); setPendingPayload(null); }; /** * Map a container size to the configured container type: reefer type when * any container on the line is refrigerated, standard type otherwise — * mirroring the server's own size→type resolution for customers. */ function buildPayload( values: ShipmentFormValues, ): CompleteShippingLineBookingPayload | { error: string } { const base = { scheduledDate: values.scheduledDate, // Which of the line's trains the booking rides — required by the server // when more than one departs on the chosen day. ...(values.trainScheduleId ? { trainScheduleId: values.trainScheduleId } : {}), ...(values.paymentCurrency ? { paymentCurrency: values.paymentCurrency } : {}), ...(values.cargoDescription?.trim() ? { cargoFreeText: values.cargoDescription.trim() } : {}), }; if (!isContainer) { if (!cargoTypeId) return { error: "Select the cargo type." }; return { ...base, cargoTypeId, cargoWeightTons: Number(values.cargoWeightTons), bulkHazardousQuantity: Number(values.bulkHazardousQuantity || 0), bulkReeferQuantity: Number(values.bulkReeferQuantity || 0), }; } const containers: CompleteBookingContainerLine[] = []; for (const l of values.containers.filter( (l) => Number(l.quantity) >= 1, )) { const totalVgm = l.units.reduce((s, u) => s + Number(u.vgmTons || 0), 0); containers.push({ // The size string alone — the server maps it to the configured // container type, so a slow/failed catalog fetch can never block // the submit with a phantom "type not configured" error. containerSize: l.containerSize, quantity: Number(l.quantity), hazardousQuantity: l.units.filter((u) => u.isHazardous).length, reeferQuantity: l.units.filter((u) => u.isReefer).length, vgmPerUnitTons: l.units.length ? totalVgm / l.units.length : 0, units: l.units.map((u) => ({ containerNumber: u.containerNumber.trim().toUpperCase(), sealNumber: u.sealNumber || undefined, vgmTons: Number(u.vgmTons), isHazardous: Boolean(u.isHazardous), isReefer: Boolean(u.isReefer), })), }); } return { ...base, containers }; } const [payloadError, setPayloadError] = useState(null); const handleSubmit = form.handleSubmit((values) => { if (hasOdd20ft) return; const payload = buildPayload(values); if ("error" in payload) { setPayloadError(payload.error); return; } setPayloadError(null); // Open the confirm dialog now and price into it; the booking submits only // after the shipping line confirms the figure. setPendingPayload(payload); previewMutation.reset(); previewMutation.mutate(payload); }); const handleConfirm = () => { if (!pendingPayload || !quote) return; // Guard: never let unresolved 20ft pairing errors submit — the server // rejects them anyway; the disabled button just says so first. if (quote.pairingErrors.length > 0) return; submitMutation.mutate(pendingPayload); }; const showValidationSummary = form.formState.isSubmitted && !form.formState.isValid; return ( {isResubmit ? "Change Your Booking" : "Complete Your Booking"} {isResubmit ? `Update the details below and pick a new shipment day, then resubmit booking ${booking.reference}.` : `Your documents are approved — enter the cargo details and shipment day for booking ${booking.reference}. The charge goes on your credit account.`}
e.preventDefault()} > {/* Errors surface in a modal, not an inline alert: the submit button sits at the bottom of a long form, so a banner at the top scrolls out of sight exactly when the user needs it. */} { setPayloadError(null); submitMutation.reset(); previewMutation.reset(); }} centered radius="md" size="lg" title={ Booking could not be completed } overlayProps={{ blur: 2, backgroundOpacity: 0.55 }} > {(() => { // The server's message, never axios's generic "Request failed // with status code 400" — extractApiError digs it out of the // response body. const message = payloadError ?? extractApiError(submitMutation.error ?? previewMutation.error) .message; // Missing-rate blocks are an EDR setup gap, not a mistake the // shipping line made — lead with plain words and where to go, // then the specifics of what is not priced. const isPricingGap = message.includes("rate is configured"); // The server joins multiple pricing blocks with "; " — a list // reads far better than one wall of text. const parts = message .split("; ") .map((p) => p.trim()) .filter(Boolean); return ( <> {isPricingGap && ( This booking cannot be completed yet — the price for your shipment has not been set up. Please contact EDR support to configure it, or change the options below: )} {parts.length > 1 ? ( {parts.map((part, i) => ( {part} ))} ) : ( {message} )} ); })()} {isContainer ? ( ) : ( )} {showValidationSummary ? ( } mb="sm" > Fix the highlighted fields before completing the booking. ) : null}
); } /** * Price confirmation — the customer shipment form's modal, one-to-one: opens * the moment the form submits, shows a loader while the server prices and * checks 20ft pairing, then the authoritative breakdown. Pairing violations * hard-block confirm (the server rejects them on /complete too); overweight * containers only warn — the surcharge is already inside the total. */ function PriceConfirmModal({ opened, quote, quoteLoading, loading, onConfirm, onReject, }: { opened: boolean; quote: ShippingLinePriceQuote | null; quoteLoading: boolean; loading: boolean; onConfirm: () => void; onReject: () => void; }) { const pairingErrors = quote?.pairingErrors ?? []; const hasPairingBlock = pairingErrors.length > 0; const overweightLines = quote?.overweightLines ?? []; const overweightSurchargeAmount = quote?.lineItems.find((li) => li.code === "OVERWEIGHT_PER_TON")?.amount ?? 0; // Confirm waits for the authoritative price and a clean pairing check. const confirmDisabled = loading || quoteLoading || hasPairingBlock || !quote; return ( Confirm shipment price Review the total before booking this shipment. } > {quoteLoading && ( Computing the final price breakdown and checking container weights… )} {hasPairingBlock && ( } title="Cannot complete booking — 20ft wagon pairing" > {pairingErrors.map((msg, i) => ( {msg} ))} Adjust the 20ft container weights or quantities so pairs differ by no more than 10 tons. )} {overweightLines.length > 0 && ( } title="Overweight containers" > {overweightLines.map((line, i) => ( {line.containerLabel || line.containerTypeCode}:{" "} {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t (+ {line.excessTons}t overweight) ))} {overweightSurchargeAmount > 0 ? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${ quote?.currency ?? "" } applies (included in the total below). You can still submit, or go back and adjust weights.` : "An overweight surcharge applies. You can still submit, or go back and adjust weights."} )} {quote && quote.warnings.length > 0 && ( } > {quote.warnings.join(" ")} )} {quote && ( {quote.lineItems.map((line, i) => ( {line.description} {(line.quantity ?? 1).toLocaleString()} ×{" "} {formatAmount(line.unitAmount ?? line.amount)}{" "} {quote.currency} {line.unit ? ` · ${formatRateUnit(line.unit.toLowerCase())}` : ""} {formatAmount(line.amount)} {quote.currency} ))} {quote.lineItems.length === 0 && ( No priced lines — check the cargo details. )} Total {formatAmount(quote.totalAmount)}{" "} {quote.currency} The amount is charged to your credit account — no payment is due now. EDR bills your accumulated charges periodically. )} ); } /** The booking's lane — fixed at initiate time from the chosen route. */ function RouteCard({ booking }: { booking: ShippingLineBooking }) { return ( } title="Route" description="This booking ships on the lane picked when it was initiated." /> {booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "} {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} {booking.tradeDirection ?? "IMPORT"} ); } /** A departure's calendar day in East Africa Time — matches the server's eatDay. */ function eatDayOf(date: string | Date): string { return new Date(date).toLocaleDateString("en-CA", { timeZone: "Africa/Addis_Ababa", }); } function ScheduleStep({ form, booking, cargoTypeId, }: { form: ShipmentForm; booking: ShippingLineBooking; /** Bulk cargo pick (container bookings pass null) — refines availability. */ cargoTypeId: string | null; }) { // Days come from the line's dedicated trains (open until each train's // close offset), or the shared pool when the lane has none. const daysQuery = useQuery({ queryKey: ["shipping-line-bookings", booking.id, "available-days"], queryFn: () => shippingLineBookingsService.availableDays(booking.id), }); // Cargo context makes the per-train availability figures real: the wagon // types offered are the ones THIS cargo can ride. Recomputed per render on // purpose — react-hook-form mutates the watched array in place. const isContainer = (booking.freightType ?? "CONTAINER") === "CONTAINER"; const containerLines = form.watch("containers") ?? []; const cargo = (() => { if (isContainer) { const sizes = containerLines .filter((l) => Number(l.quantity || 0) >= 1) .map((l) => l.containerSize); const ft20 = containerLines .filter((l) => l.containerSize === "20ft") .reduce((s, l) => s + Number(l.quantity || 0), 0); const ft40 = containerLines .filter((l) => l.containerSize === "40ft") .reduce((s, l) => s + Number(l.quantity || 0), 0); const wagons = Math.ceil(ft20 / 2) + ft40; return { containerSizes: sizes.length ? sizes : undefined, wagons: wagons > 0 ? wagons : undefined, }; } return cargoTypeId ? { cargoTypeId } : {}; })(); // The line's OWN departures on this lane, each with per-wagon-type free // space — the pick is "which train", not an abstract calendar day. // Customers never see these trains; the endpoint is shipping-line only. const trainsQuery = useQuery({ queryKey: [ "shipping-line-bookings", booking.id, "trains", cargo.containerSizes?.join(",") ?? "", (cargo as { cargoTypeId?: string }).cargoTypeId ?? "", (cargo as { wagons?: number }).wagons ?? 0, ], queryFn: () => shippingLineBookingsService.trainsForDay(booking.id, undefined, cargo), }); const options = trainsQuery.data ?? []; const openDays = new Set(daysQuery.data?.days ?? []); const selectedTrainId = form.watch("trainScheduleId"); return ( } title="Schedule" description={ options.length ? "Pick the train your shipment rides — these departures are dedicated to your company. A booking rides one train." : "Pick the binding shipment day. Only days with a train departure on your route can be selected." } /> {/* No currency choice: shipping-line charges always land on the ETB credit ledger — the form defaults paymentCurrency to ETB and the server enforces it. */} ( {options.length ? "Your train *" : "Shipment day *"} {trainsQuery.isLoading ? ( Checking wagon availability on your trains… ) : options.length ? ( {options.map((option) => { const day = eatDayOf(option.departure); const selected = selectedTrainId === option.scheduleId; // Closed when its own cut-off passed OR the day fell out // of the bookable set. const closed = !option.isOpen || !openDays.has(day); const showFit = (option.neededWagons ?? 0) > 0; return ( { if (closed) return; field.onChange(day); form.setValue("trainScheduleId", option.scheduleId, { shouldValidate: true, }); }} style={{ cursor: closed ? "not-allowed" : "pointer", opacity: closed ? 0.55 : 1, borderColor: selected ? "var(--mantine-color-teal-6)" : undefined, borderWidth: selected ? 2 : 1, background: selected ? "var(--mantine-color-teal-0)" : undefined, }} > {option.trainNumber ?? option.trainName ?? "Train"} {option.trainName && option.trainNumber ? ( {option.trainName} ) : null} {closed && ( Booking closed )} {!closed && showFit && ( {option.fits ? "Fits your cargo" : "Not enough space"} )} {/* Free wagons by TYPE — what this train can still take for the entered cargo. */} {option.byWagonType.length ? ( option.byWagonType.map((t) => ( 0 ? "teal" : "gray" } > {t.code ?? t.name ?? "Wagons"} ·{" "} {t.freeWagons} free )) ) : ( Enter your cargo to see wagon availability by type. )} Departs{" "} {new Date(option.departure).toLocaleString( undefined, { weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }, )} {option.bookingClosesAt && ( Book before{" "} {new Date( option.bookingClosesAt, ).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", })} )} ); })} ) : ( field.onChange(d)} /> )} {fieldState.error?.message && ( {fieldState.error.message} )} )} /> ); } function ContainerCargoStep({ form }: { form: ShipmentForm }) { // Seed one shipment line per size exactly once. const seededRef = useRef(false); useEffect(() => { if (seededRef.current) return; seededRef.current = true; const existing = form.getValues("containers") ?? []; if (existing.length > 0) return; form.setValue( "containers", SIZES.map((size) => ({ containerSize: size, quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", units: [], })), { shouldValidate: false }, ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const lines = form.watch("containers") ?? []; // 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: [...SIZES], includeHazardous: true, includeReefer: true, includeReturn: false, }; const handleImportFile = async (file: File | null) => { // Reset the hidden input so re-picking the same (fixed) file re-fires. importResetRef.current?.(); if (!file) return; const { rows, errors } = await parseContainerExcel(file, excelOpts); if (errors.length > 0) { setImportSummary(null); setImportErrors(errors); return; } // Replace only the lines for sizes present in the file; a size the file // omits keeps whatever was already entered for it. const current = form.getValues("containers") ?? []; const next = SIZES.map((size) => { const imported = rows.filter((r) => r.containerSize === size); if (imported.length === 0) { return ( current.find((l) => l.containerSize === size) ?? { containerSize: size, quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", units: [], } ); } return { containerSize: size, quantity: String(imported.length), hazardousQuantity: String(imported.filter((r) => r.hazardous).length), reeferQuantity: String(imported.filter((r) => r.reefer).length), returnQuantity: "0", // The spreadsheet already 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: r.vgmTons, isHazardous: Boolean(r.hazardous), isReefer: Boolean(r.reefer), isReturn: false, })), }; }); form.setValue("containers", next, { shouldValidate: true, shouldDirty: true, }); setImportErrors([]); setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`); }; return ( } title="Cargo Details" description="Enter the quantity and per-container details for each size." /> Import containers from Excel One row per container. Importing fills the lines below for the sizes in your file. {(props) => ( )} {importErrors.length > 0 && ( } title="Import failed — fix the file and try again" mt="sm" > {importErrors.slice(0, 8).map((msg, i) => ( {msg} ))} {importErrors.length > 8 && ( …and {importErrors.length - 8} more. )} )} {importSummary && ( } mt="sm" > {importSummary} )} (