Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
Marshal b5a97d344a train
2026-07-14 13:10:00 +00:00

1710 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Center,
Divider,
FileButton,
Group,
Loader,
Modal,
Paper,
Select,
Stack,
Switch,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
FileDown,
FileText,
FileUp,
MapPin,
Package,
Receipt,
Repeat,
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service";
import { 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<HTMLInputElement>) => {
if (event.key === "-") event.preventDefault();
};
interface UnitErrors {
containerNumber?: string;
vgmTons?: string;
}
interface LineErrors {
quantity?: string;
hazardousQuantity?: string;
reeferQuantity?: 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;
}
/** 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: UnitDraft[];
}
interface BulkDraft {
cargoWeightTons: string;
itemCount: string;
hazardousQuantity: string;
reeferQuantity: string;
}
function emptyUnit(): UnitDraft {
return { containerNumber: "", sealNumber: "", vgmTons: "" };
}
function emptyLine(size: string): ContainerLineDraft {
return {
containerSize: size,
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
units: [emptyUnit()],
};
}
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("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [bulk, setBulk] = useState<BulkDraft>({
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";
// 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 ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
title="This booking must take the whole split remainder"
>
<Text fz={13}>
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.
</Text>
</Alert>
) : 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<string>();
(contract?.cargoScope ?? []).forEach((s) => {
if (s.containerSize) sizes.add(s.containerSize);
});
return [...sizes];
}, [contract?.cargoScope]);
useEffect(() => {
if (!bookingRequest || prefilled) 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),
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;
setPrefilled(true);
setContainerLines(
lines.map((c) => {
const qty = Math.max(1, c.quantity);
return {
containerSize: String(c.containerType?.sizeFt ?? ""),
quantity: String(qty),
hazardousQuantity: "0",
reeferQuantity: "0",
units: Array.from({ length: qty }, emptyUnit),
};
}),
);
}, [copyFromBooking, prefilled]);
// 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),
})),
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<Freight.AvailableDaysForCargoQuery | null>(() => {
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,
});
// 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 { ...line, units: next };
}),
);
};
const patchLine = (idx: number, patch: Partial<ContainerLineDraft>) =>
setContainerLines((prev) =>
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
);
const patchUnit = (
lineIdx: number,
unitIdx: number,
patch: Partial<UnitDraft>,
) =>
patchLine(lineIdx, {
units: containerLines[lineIdx].units.map((u, i) =>
i === unitIdx ? { ...u, ...patch } : u,
),
});
// 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<string[]>([]);
const [importSummary, setImportSummary] = useState<string | null>(null);
const importResetRef = useRef<(() => void) | null>(null);
const excelOpts = {
allowedSizes: containerSizes,
includeHazardous: contract?.isHazardous ?? false,
includeReefer: contract?.isReefer ?? false,
};
const handleImportFile = async (file: File | null) => {
// Reset the hidden input so re-picking the same (fixed) file re-fires.
importResetRef.current?.();
if (!file) return;
const { rows, errors } = await parseContainerExcel(file, excelOpts);
if (errors.length > 0) {
setImportSummary(null);
setImportErrors(errors);
return;
}
// Replace only the lines for sizes present in the file; a contracted size
// the file omits keeps whatever was already entered for it.
setContainerLines((prev) =>
containerSizes.map((size) => {
const imported = rows.filter((r) => r.containerSize === size);
if (imported.length === 0) {
return prev.find((l) => l.containerSize === size) ?? 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),
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: String(r.vgmTons),
})),
};
}),
);
setImportErrors([]);
setShowErrors(false);
setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`);
};
const unitErrors = useMemo<UnitErrors[][]>(() => {
if (!isContainer) return [];
const numberCounts = new Map<string, number>();
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<LineErrors[]>(() => {
if (!isContainer || !contract) return [];
return containerLines.map((line) => {
const errs: LineErrors = {};
const qty = Number(line.quantity || 0);
if (line.quantity.trim() === "") {
errs.quantity = "Quantity is required.";
} else if (Number.isNaN(qty) || qty < 1) {
errs.quantity = "At least 1.";
} else if (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.`;
}
}
return errs;
});
}, [isContainer, contract, containerLines]);
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
const bulkErrors = useMemo<BulkErrors>(() => {
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 cargoValid = isContainer
? lineErrors.every(
(e) =>
!e.quantity && !e.units && !e.hazardousQuantity && !e.reeferQuantity,
) &&
unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
)
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
const formValid = cargoValid && !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 } : {}),
// Intercity bookings carry no date — staff assign a passing train later.
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
...(notes.trim() ? { notes: notes.trim() } : {}),
// Equipment return is a container concern — bulk keeps the contract default.
...(isContainer
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
: {}),
};
if (isContainer) {
payload.containers = containerLines
.filter((l) => 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,
})),
}));
} 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),
});
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
}
}
if (contract.contractKind === "GENERAL") {
// GENERAL per-booking clearance: land on the booking's clearance
// detail — the same page the Shipments tab on the hub opens.
navigate(`/dashboard/clearance/${booking.id}`);
} else {
// ONE_TIME customs keeps its clearance on the contract.
navigate(`/dashboard/contracts/clearance/${contract.id}`);
}
},
});
};
if (isLoading || windowsLoading) {
return (
<PageContainer>
<Center mih="50vh">
<Loader color="edr-green" />
</Center>
</PageContainer>
);
}
if (!contract) {
return (
<PageContainer>
<PageHeader
title="Contract not found"
backTo="/dashboard/contracts/clearance"
/>
</PageContainer>
);
}
const header = (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
{completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{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}.`}
</Text>
</Box>
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => navigate(`/dashboard/contracts/clearance/${contract.id}`)}
>
Back to clearance
</Button>
</Group>
);
// Coarse gate — same closed-state notice the customer sees on the portal.
// Intercity contracts are never window-gated.
if (!isIntercity && !windowOpen) {
return (
<PageContainer>
{header}
<Alert
color="yellow"
variant="light"
radius="md"
icon={<CalendarDays size={18} />}
title="Booking is not open right now"
>
<Text size="sm">
{nextWindow?.windowOpensAt ? (
<>
Next window: <b>{fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT</b>{" "}
for{" "}
<b>
{nextWindow.origin ?? "Origin"} {" "}
{nextWindow.destination ?? "Destination"}
</b>
.
</>
) : (
<>No upcoming booking window scheduled.</>
)}
</Text>
<Text size="sm" mt="xs">
Come back when the booking window opens to book this shipment.
</Text>
</Alert>
</PageContainer>
);
}
return (
<PageContainer>
{header}
{bookingRequest ? (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<FileText size={16} />}
title="From shipment request"
mb="lg"
>
Booking on behalf of the customer for request{" "}
<b>{bookingRequest.reference}</b>.
{bookingRequest.scheduledDate ? (
<>
{" "}
Customer requested{" "}
<b>
{new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(bookingRequest.scheduledDate))}
</b>{" "}
set the binding shipment date below.
</>
) : null}
</Alert>
) : null}
<Stack gap="lg" maw={896} mx="auto">
<StepCard>
<StepHeader
icon={<MapPin size={22} />}
title="Route"
description={
multiRoute
? "Choose which contracted route this shipment ships on."
: "This shipment ships on the contract's only route."
}
/>
{multiRoute ? (
<Select
label="Contract route *"
placeholder="Select a route..."
value={contractRouteId}
onChange={setContractRouteId}
error={showErrors ? routeError : undefined}
data={routes.map((r) => ({
value: r.id,
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"}${
r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"
}`,
}))}
radius={10}
styles={fieldStyles}
/>
) : (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Text fz={14} fw={600}>
{selectedRoute?.originYard?.label ??
selectedRoute?.originYard?.code ??
"—"}{" "}
{" "}
{selectedRoute?.destinationYard?.label ??
selectedRoute?.destinationYard?.code ??
"—"}
</Text>
<Text fz={12} c="dimmed" mt={2}>
{contract.tradeDirection}
</Text>
</Paper>
)}
</StepCard>
{isContainer ? (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Enter the quantity and per-container details for each size in the contract scope."
/>
<Stack gap={18}>
{containerSizes.length > 0 && (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Group justify="space-between" wrap="wrap" gap="sm">
<Box>
<Text fz={13} fw={600}>
Import containers from Excel
</Text>
<Text fz={12} c="dimmed">
One row per container. Importing fills the lines below
for the sizes in the file.
</Text>
</Box>
<Group gap="sm">
<Button
variant="default"
size="xs"
radius="md"
leftSection={<FileDown size={14} />}
onClick={() => downloadContainerImportTemplate(excelOpts)}
>
Download template
</Button>
<FileButton
resetRef={importResetRef}
accept=".xlsx,.xls"
onChange={handleImportFile}
>
{(props) => (
<Button
{...props}
size="xs"
radius="md"
color="edr-green"
leftSection={<FileUp size={14} />}
>
Import Excel
</Button>
)}
</FileButton>
</Group>
</Group>
{importErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Import failed — fix the file and try again"
mt="sm"
>
<Stack gap={4}>
{importErrors.slice(0, 8).map((msg, i) => (
<Text key={i} fz="xs">
{msg}
</Text>
))}
{importErrors.length > 8 && (
<Text fz="xs" c="dimmed">
and {importErrors.length - 8} more.
</Text>
)}
</Stack>
</Alert>
)}
{importSummary && (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<CheckCircle2 size={16} />}
mt="sm"
>
<Text fz="xs">{importSummary}</Text>
</Alert>
)}
</Paper>
)}
{remainderNotice}
<ContractCapacityNotice contractId={contract.id} isContainer />
{containerLines.length === 0 ? (
<Text fz="sm" c="dimmed">
This contract has no container sizes in scope.
</Text>
) : (
containerLines.map((line, lineIdx) => (
<Box
key={`${line.containerSize}-${lineIdx}`}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 16 }}
>
<Text fz={14} fw={700} mb={10}>
{line.containerSize} containers
</Text>
<Group gap={12} grow mb={12} align="flex-start">
<TextInput
type="number"
onKeyDown={blockNegative}
label="Quantity *"
min={1}
value={line.quantity}
error={
showErrors
? (lineErrors[lineIdx]?.quantity ??
lineErrors[lineIdx]?.units)
: undefined
}
onChange={(e) => {
patchLine(lineIdx, { quantity: e.currentTarget.value });
syncUnits(lineIdx, Number(e.currentTarget.value || 0));
}}
radius={10}
styles={fieldStyles}
/>
{contract.isHazardous && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Hazardous qty"
min={0}
value={line.hazardousQuantity}
error={
showErrors
? lineErrors[lineIdx]?.hazardousQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
hazardousQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
{contract.isReefer && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Reefer qty"
min={0}
value={line.reeferQuantity}
error={
showErrors
? lineErrors[lineIdx]?.reeferQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
reeferQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
</Group>
<StepLabel>Per-container details</StepLabel>
<Stack gap={10} mt={8}>
{line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} grow align="flex-start">
<TextInput
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.containerNumber
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
containerNumber:
e.currentTarget.value.toUpperCase(),
})
}
radius={10}
styles={fieldStyles}
/>
<TextInput
label={unitIdx === 0 ? "Seal number" : undefined}
placeholder="Optional"
value={unit.sealNumber}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
<TextInput
type="number"
onKeyDown={blockNegative}
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5"
min={0}
step={0.01}
value={unit.vgmTons}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.vgmTons
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
vgmTons: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
</Group>
))}
</Stack>
</Box>
))
)}
</Stack>
</StepCard>
) : (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Enter the amount you are shipping for this booking."
/>
<Stack gap={14}>
{remainderNotice}
<ContractCapacityNotice contractId={contract.id} isContainer={false} />
<TextInput
type="number"
onKeyDown={blockNegative}
label="Quantity (tons)"
placeholder="e.g. 1200"
min={0}
step={0.01}
value={bulk.cargoWeightTons}
error={
showErrors && bulkUom === "PER_TON"
? bulkErrors.quantity
: undefined
}
onChange={(e) =>
setBulk((b) => ({ ...b, cargoWeightTons: e.currentTarget.value }))
}
radius={10}
styles={fieldStyles}
/>
<TextInput
type="number"
onKeyDown={blockNegative}
label="Item count (if applicable)"
placeholder="e.g. 500"
min={0}
step={1}
value={bulk.itemCount}
error={
showErrors && bulkUom === "PER_ITEM"
? bulkErrors.quantity
: undefined
}
onChange={(e) =>
setBulk((b) => ({ ...b, itemCount: e.currentTarget.value }))
}
radius={10}
styles={fieldStyles}
/>
{contract.isHazardous && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Hazardous quantity"
min={0}
step={1}
value={bulk.hazardousQuantity}
error={showErrors ? bulkErrors.hazardous : undefined}
onChange={(e) =>
setBulk((b) => ({
...b,
hazardousQuantity: e.currentTarget.value,
}))
}
radius={10}
styles={fieldStyles}
/>
)}
{contract.isReefer && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Refrigerated quantity"
min={0}
step={1}
value={bulk.reeferQuantity}
error={showErrors ? bulkErrors.reefer : undefined}
onChange={(e) =>
setBulk((b) => ({
...b,
reeferQuantity: e.currentTarget.value,
}))
}
radius={10}
styles={fieldStyles}
/>
)}
</Stack>
</StepCard>
)}
{isContainer ? (
<StepCard>
<StepHeader
icon={<Repeat size={22} />}
title="Equipment Return"
description="Choose whether the empty container(s) come back to EDR after unloading."
/>
<Paper
withBorder
radius="md"
p="md"
style={{
borderColor: withReturn ? "#CDEBDD" : "#E6ECF2",
background: withReturn ? "#F6FBF8" : "white",
cursor: "pointer",
transition: "border-color 150ms ease, background 150ms ease",
}}
onClick={() => setWithReturn((v) => !v)}
>
<Group justify="space-between" wrap="nowrap" gap="md">
<Group gap={13} wrap="nowrap" align="flex-start">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: withReturn ? "#ECF6F1" : "#F1F4F7",
color: withReturn ? "#0A6F4D" : "#6B7C8E",
}}
>
<Repeat size={18} />
</Box>
<Box>
<Text fz={14} fw={700}>
With return
</Text>
<Text fz={12} c="dimmed" style={{ lineHeight: 1.4 }}>
{withReturn
? "Container(s) returned to EDR after unloading."
: "Container(s) retained by the customer after delivery."}
</Text>
</Box>
</Group>
<Switch
size="md"
color="edr-green"
aria-label="With return"
checked={withReturn}
onChange={(e) => setWithReturn(e.currentTarget.checked)}
onClick={(e) => e.stopPropagation()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
</StepCard>
) : null}
{isIntercity ? (
<StepCard>
<StepHeader
icon={<CalendarDays size={22} />}
title="Schedule"
description="Intercity shipments have no fixed day."
/>
<Alert color="blue" variant="light" radius="md" icon={<AlertCircle size={16} />}>
The shipment rides the next import/export train passing through
the corridor. Operations assign it to a train with free capacity
the customer is notified when it is accepted and payment is due.
</Alert>
</StepCard>
) : (
<StepCard>
<StepHeader
icon={<CalendarDays size={22} />}
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 ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Enter your cargo details first available shipment days depend
on the wagons your cargo needs.
</Alert>
) : (
<Box>
<StepLabel>Shipment day *</StepLabel>
<Box mt={10} w="100%">
<OperationDatePicker
fullWidth
availableDays={availableDays ?? []}
isLoading={daysLoading}
value={scheduledDate}
onChange={setScheduledDate}
/>
</Box>
{showErrors && dateError && (
<Text fz="xs" c="red" mt={6}>
{dateError}
</Text>
)}
</Box>
)}
</StepCard>
)}
<StepCard>
<Textarea
label="Additional notes"
placeholder="Any special instructions for this shipment…"
rows={3}
radius="md"
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
/>
</StepCard>
</Stack>
<Box
style={{
position: "sticky",
bottom: 0,
zIndex: 20,
borderTop: "1px solid var(--mantine-color-gray-3)",
backgroundColor: "rgba(255,255,255,0.94)",
backdropFilter: "blur(14px)",
padding: "16px 0",
marginTop: 24,
}}
>
<Box maw={896} mx="auto">
{showErrors && !formValid ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted fields before reviewing the price.
</Alert>
) : null}
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
</Group>
</Box>
</Box>
<Modal
opened={priceOpen}
onClose={() => {
if (!submitPending) setPriceOpen(false);
}}
closeOnClickOutside={!submitPending}
closeOnEscape={!submitPending}
withCloseButton={!submitPending}
centered
radius="lg"
size="lg"
title={
<Group gap={10}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Receipt size={18} />
</ThemeIcon>
<Box>
<Text fw={800} fz={16}>
Confirm shipment price
</Text>
<Text fz="xs" c="dimmed">
Booking on behalf of the customer for {contract.reference}.
</Text>
</Box>
</Group>
}
>
{displayTotal ? (
<Stack gap="md">
{validateShipmentMutation.isPending && (
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Computing the final price breakdown and checking container
weights
</Text>
</Group>
)}
{pairingErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — 20ft wagon pairing"
>
<Stack gap={6}>
{pairingErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Adjust the 20ft container weights or quantities so pairs
differ by no more than 10 tons.
</Text>
</Stack>
</Alert>
)}
{capacityErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — over maximum capacity"
>
<Stack gap={6}>
{capacityErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Reduce the cargo weight or split it across more containers
to book this shipment.
</Text>
</Stack>
</Alert>
)}
{containerClashErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — container already booked"
>
<Stack gap={6}>
{containerClashErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
A container can only be on one booking per train remove it
or pick another shipment day.
</Text>
</Stack>
</Alert>
)}
{spaceErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — not enough train space"
>
<Stack gap={6}>
{spaceErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
</Stack>
</Alert>
)}
{overweightLines.length > 0 && (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
title="Overweight containers"
>
<Stack gap={6}>
{overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds
limit {line.maxAllowedTons}t (+{line.excessTons}t
overweight)
</Text>
))}
<Text fz="xs" c="#9A5B00" mt={2}>
{overweightSurchargeAmount > 0
? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${
validation?.currency ?? displayTotal.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."}
</Text>
</Stack>
</Alert>
)}
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
<Stack gap={10}>
{displayTotal.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={500}>
{line.label}
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
{line.amount.toLocaleString()} {displayTotal.currency}
</Text>
</Group>
))}
{displayTotal.lines.length === 0 && (
<Text fz="sm" c="dimmed">
No priced lines check the cargo details.
</Text>
)}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="edr-green"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28}>
{displayTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{displayTotal.currency}
</Text>
</Text>
</Group>
</Paper>
<Group justify="space-between" mt="xs">
<Button
variant="default"
radius="md"
leftSection={<X size={16} />}
onClick={() => setPriceOpen(false)}
disabled={submitPending}
>
Reject &amp; edit
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
loading={submitPending}
disabled={confirmDisabled}
onClick={handleSubmit}
>
{completeBookingId ? "Confirm & complete" : "Confirm & book"}
</Button>
</Group>
</Stack>
) : null}
</Modal>
</PageContainer>
);
}