Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
2026-08-24 12:24:23 +00:00

2634 lines
98 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,
type ReactNode,
} from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
Divider,
FileButton,
Group,
Loader,
Modal,
Paper,
Select,
Stack,
Switch,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
FileDown,
FileText,
FileUp,
Flame,
Link2,
MapPin,
Package,
Receipt,
Repeat,
Snowflake,
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
import {
CurrencySelector,
ExportTrainPicker,
OperationDatePicker,
} from "@edr/ui-common";
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import {
contractsService,
type ConsolidationCandidate,
} from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import {
useContractCapacity,
useContractDetail,
useContractMutations,
} from "@/hooks/contracts/useContracts";
import {
computeGlShipmentTotal,
formatRateUnit,
type GlShipmentQuantities,
} from "./gl-booking-form/total";
import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice";
import {
downloadContainerImportTemplate,
parseContainerExcel,
} from "./gl-booking-form/container-excel";
import {
fieldStyles,
StepCard,
StepHeader,
StepLabel,
} from "./gl-booking-form/form-ui";
import {
ConsolidationPartnerPanel,
emptyPartnerLine,
} from "./gl-booking-form/ConsolidationPartnerPanel";
import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
/**
* Container sizes offered on the parent-booking panel. Fixed rather than taken
* from this contract's scope: the parent booking is a different customer on a
* different contract, so its sizes are its own.
*/
const PARTNER_SIZES = ["20ft", "40ft"];
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
// ISO 6346: 4-letter owner/category code + 6-digit serial + check digit.
// Same rule the customer portal shipment form enforces.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
/**
* Every quantity on this form is a non-negative magnitude. A native number
* input's `min` only constrains its stepper, so swallow the minus key before it
* can put a negative into the field at all. Same guard as the portal form.
*/
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "-") event.preventDefault();
};
interface UnitErrors {
containerNumber?: string;
vgmTons?: string;
}
interface LineErrors {
quantity?: string;
hazardousQuantity?: string;
reeferQuantity?: string;
returnQuantity?: string;
units?: string;
}
interface BulkErrors {
quantity?: string;
hazardous?: string;
reefer?: string;
}
function fmtWindowOpensAt(iso: string): string {
const date = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: EAT_TZ,
});
const time = new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: EAT_TZ,
});
return `${date} · ${time}`;
}
interface UnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: string;
/** Handling is per physical container; the line counts roll these up. */
isHazardous: boolean;
isReefer: boolean;
isReturn: boolean;
}
/** Mirrors the portal shipment form's container line: line-level quantity +
* hazardous/reefer counts with per-unit number/seal/VGM details. */
interface ContainerLineDraft {
containerSize: string;
quantity: string;
hazardousQuantity: string;
reeferQuantity: string;
/** Units of this line shipping with empty-container return (contract WITH_RETURN only). */
returnQuantity: string;
units: UnitDraft[];
}
interface BulkDraft {
cargoWeightTons: string;
itemCount: string;
hazardousQuantity: string;
reeferQuantity: string;
}
function emptyUnit(): UnitDraft {
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
};
}
function emptyLine(size: string): ContainerLineDraft {
return {
containerSize: size,
quantity: "0",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [],
};
}
function bulkUnitOfMeasure(
contract: Freight.IContract,
): "PER_TON" | "PER_ITEM" {
const hasPerItem = contract.pricingBreakdown?.lineItems?.some(
(li) => li.unit === "per_item",
);
return hasPerItem ? "PER_ITEM" : "PER_TON";
}
export default function GlCreateBookingForm() {
// With `bookingId` the form runs in COMPLETION mode: the bare instance
// (auto-initiated by the customer's shipment request) already finished its
// per-booking customs clearance, and this form supplies the deferred cargo
// (container numbers, VGM) + binding shipment day. Same window gate, same
// validation and price confirmation — the submit completes the existing
// booking instead of creating a new one.
const { id, bookingId: completeBookingId } = useParams<{
id: string;
bookingId?: string;
}>();
const [searchParams] = useSearchParams();
const requestIdParam = searchParams.get("requestId");
// Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same
// contract (GL only picks a new schedule). Set by the clearance Rebook action.
const copyFromParam = searchParams.get("copyFrom");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
// Completion mode without an explicit ?requestId=: find the shipment request
// that initiated this instance so the quantities still prefill.
const { data: contractRequests } = useQuery({
queryKey: ["shipment-requests-for-contract", id],
queryFn: () => contractsService.listBookingRequests(id!),
enabled: Boolean(id) && Boolean(completeBookingId) && !requestIdParam,
});
const requestId =
requestIdParam ??
(completeBookingId
? (contractRequests?.find(
(r) => r.createdBookingId === completeBookingId,
)?.id ?? null)
: null);
const { data: bookingRequest } = useQuery({
queryKey: ["shipment-request", requestId],
queryFn: () => contractsService.getBookingRequest(requestId!),
enabled: Boolean(requestId),
});
// The expired booking a Rebook is copying from (its cargo seeds the form).
const { data: copyFromBooking } = useQuery({
queryKey: ["rebook-copy-from", copyFromParam],
queryFn: () => bookingsService.getById(copyFromParam!),
enabled: Boolean(copyFromParam),
});
// The booking being completed — used to name the customer on the price
// confirmation when a second booking's price is shown beside it.
const { data: completeBooking } = useQuery({
queryKey: ["gl-complete-booking", completeBookingId],
queryFn: () => bookingsService.getById(completeBookingId!),
enabled: Boolean(completeBookingId),
});
// Same window-gating the customer sees: booking is only allowed while a
// window is OPEN for one of the contract's routes. Intercity contracts are
// never window-gated — the shipment rides a passing train staff pick later.
const contractId = contract?.id ?? id;
const { data: bookingWindows, isLoading: windowsLoading } = useQuery({
...api.trainScheduling.contractBookingWindows.queryOptions({
input: { contractId: contractId ?? "" },
refetchInterval: 60_000,
}),
enabled: Boolean(contractId),
});
const windowOpen = useMemo(
() => (bookingWindows ?? []).some((w) => w.isOpenNow),
[bookingWindows],
);
// Next future window across all routes, used for the "next window" notice —
// the next moment booking OPENS (chronological), which may belong to a
// later-departing train. Departure-first ordering here named the soonest
// train's later opening as "next" while another lane opened earlier.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(),
)[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");
// EXPORT rail completion: the specific train GL picks for the shipment day.
const [trainScheduleId, setTrainScheduleId] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
// IMPORT bookings pick ETB or USD — starts empty so the choice is
// deliberate (required before pricing). Everything else is forced to ETB.
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = 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);
// ── Odd-20ft shared wagon (customs / Path B) ──────────────────────────────
// An odd 20ft total leaves one container unpaired. On a customs contract GL
// resolves that here by linking a second booking that is also odd — two odd
// counts always sum to even — completing both together onto the shared wagon.
const [consolidateOdd, setConsolidateOdd] = useState(false);
// Set once GL flips the toggle by hand, so the auto-on effect below never
// re-opens a panel GL deliberately closed.
const consolidateTouchedRef = useRef(false);
const [partnerPickerOpen, setPartnerPickerOpen] = useState(false);
const [partner, setPartner] = useState<ConsolidationCandidate | null>(null);
const [partnerLines, setPartnerLines] = useState<ContainerLineDraft[]>([]);
const [partnerCargoDescription, setPartnerCargoDescription] = useState("");
const seededRef = useRef(false);
const returnSeededRef = useRef(false);
// Seed the equipment-return toggle from the contract exactly once (also when
// the form is prefilled from a shipment request); GL can flip it per shipment.
useEffect(() => {
if (!contract || returnSeededRef.current) return;
returnSeededRef.current = true;
setWithReturn(contract.equipmentReturn === "WITH_RETURN");
}, [contract]);
const isContainer = contract?.freightType === "CONTAINER";
// The contract gates the empty-container return service — like hazardous.
// WITH_RETURN contracts capture a per-line return quantity instead of the
// legacy booking-level toggle; other contracts cannot switch it on.
const contractWithReturn =
isContainer && contract?.equipmentReturn === "WITH_RETURN";
// Legacy contracts (no equipment return chosen at creation) keep the old
// booking-level toggle.
const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
/**
* Handling switches offered on each container row — only the services this
* contract was created with, since the server rejects the others.
*/
const handlingColumns = (
[
contract?.isHazardous && {
key: "isHazardous",
label: "Hazardous",
icon: <Flame size={14} />,
color: "#C0392B",
},
contract?.isReefer && {
key: "isReefer",
label: "Refrigerated",
icon: <Snowflake size={14} />,
color: "#2E5B96",
},
contractWithReturn && {
key: "isReturn",
label: "With return",
icon: <Repeat size={14} />,
color: "#0A6F4D",
},
] as Array<
| false
| undefined
| { key: keyof UnitDraft; label: string; icon: ReactNode; color: string }
>
).filter(Boolean) as Array<{
key: "isHazardous" | "isReefer" | "isReturn";
label: string;
icon: ReactNode;
color: string;
}>;
// Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies.
const isIntercity = contract?.tradeDirection === "DOMESTIC";
// USD billing is offered on import traffic only — export and domestic
// shipments are always invoiced in ETB.
const isImport = contract?.tradeDirection === "IMPORT";
// ONE_TIME split-remainder mode: a previous booking on this contract was
// split on train capacity, so the capacity endpoint reports the outstanding
// remainder — the EXACT quantity this booking must take (API-enforced).
const { data: splitCapacityLines = [] } = useContractCapacity(
contract?.contractKind === "ONE_TIME" ? contract?.id : undefined,
);
const remainderLines = useMemo(
() =>
contract?.contractKind === "ONE_TIME"
? splitCapacityLines.filter((l) => (l.remaining ?? 0) > 0)
: [],
[contract?.contractKind, splitCapacityLines],
);
const remainderMode = remainderLines.length > 0;
// Bulk: prefill the exact outstanding remainder once — a different amount
// would be rejected at create anyway.
const remainderSeededRef = useRef(false);
useEffect(() => {
if (!remainderMode || isContainer || remainderSeededRef.current) return;
remainderSeededRef.current = true;
setBulk((b) =>
b.cargoWeightTons || b.itemCount
? b
: { ...b, cargoWeightTons: String(remainderLines[0].remaining) },
);
}, [remainderMode, isContainer, remainderLines]);
const remainderNotice = remainderMode ? (
<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;
// A rebook (?copyFrom=) seeds from the expired booking's real cargo —
// richer than the request's bare quantities. Let that seed win the race.
if (copyFromParam) return;
setPrefilled(true);
const lines = bookingRequest.requestedLines ?? {};
if (lines.containers?.length) {
setContainerLines(
lines.containers.map((c) => ({
containerSize: c.containerSize,
quantity: String(Math.max(1, c.quantity)),
hazardousQuantity: String(c.hazardousQuantity ?? 0),
reeferQuantity: String(c.reeferQuantity ?? 0),
returnQuantity: "0",
units: Array.from({ length: Math.max(1, c.quantity) }, emptyUnit),
})),
);
} else if (lines.bulk) {
setBulk({
cargoWeightTons:
lines.bulk.cargoWeightTons != null
? String(lines.bulk.cargoWeightTons)
: "",
itemCount:
lines.bulk.itemCount != null ? String(lines.bulk.itemCount) : "",
hazardousQuantity: String(lines.bulk.hazardousQuantity ?? 0),
reeferQuantity: "0",
});
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
// item count isn't on the booking payload yet, so bulk rebooks fall through to
// the normal contract seed and GL re-enters the quantity.)
useEffect(() => {
if (!copyFromBooking || prefilled) return;
const lines = copyFromBooking.bookingContainers ?? [];
if (!lines.length) return;
// The booking stores a numeric sizeFt (20) but the contract scope — and the
// create payload the server validates — uses its own size strings ("20ft").
// Seed with the scope's string so the rebook payload matches what a fresh
// form entry would send.
const scopeSizeForFt = (sizeFt: number | null | undefined): string => {
if (sizeFt == null) return "";
return (
containerSizes.find((s) => parseInt(s, 10) === Number(sizeFt)) ??
`${sizeFt}ft`
);
};
setPrefilled(true);
// Rebook carries the expired booking's cargo description forward.
if (copyFromBooking.cargoFreeText) {
setCargoDescription(copyFromBooking.cargoFreeText);
}
setContainerLines(
lines.map((c) => {
const qty = Math.max(1, c.quantity);
// Carry the persisted per-unit details (numbers, seals, VGM, handling)
// when the source booking has them — a rebooked EXPIRED booking does,
// and its cargo is fixed server-side anyway.
const units: UnitDraft[] =
c.units?.length === qty
? c.units.map((u) => ({
containerNumber: u.containerNumber ?? "",
sealNumber: u.sealNumber ?? "",
vgmTons: u.vgmTons != null ? String(u.vgmTons) : "",
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
isReturn: Boolean(u.isReturn),
}))
: Array.from({ length: qty }, emptyUnit);
return {
containerSize: scopeSizeForFt(c.containerType?.sizeFt),
quantity: String(qty),
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
returnQuantity: String(units.filter((u) => u.isReturn).length),
units,
};
}),
);
}, [copyFromBooking, prefilled, containerSizes]);
// Seed one shipment line per contracted size exactly once — same seeding the
// portal form does. Subsequent renders reuse the lines.
useEffect(() => {
if (!contract || prefilled || seededRef.current) return;
seededRef.current = true;
if (isContainer && containerSizes.length > 0 && containerLines.length === 0) {
setContainerLines(containerSizes.map(emptyLine));
}
}, [contract, prefilled, isContainer, containerSizes, containerLines.length]);
const quantities: GlShipmentQuantities = useMemo(
() => ({
isContainer: Boolean(isContainer),
containers: containerLines.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity || 0),
hazardousQuantity: Number(l.hazardousQuantity || 0),
reeferQuantity: Number(l.reeferQuantity || 0),
returnQuantity: Number(l.returnQuantity || 0),
})),
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
bulkReeferQuantity: Number(bulk.reeferQuantity || 0),
}),
[isContainer, containerLines, bulk],
);
const priceTotal = useMemo(
() => (contract ? computeGlShipmentTotal(contract, quantities) : null),
[contract, quantities],
);
const selectedRoute = useMemo(
() => routes.find((r) => r.id === contractRouteId) ?? routes[0],
[routes, contractRouteId],
);
// Read the cargo entered above so the day list reflects what can actually be
// shipped (matching wagons + open train capacity) — portal parity.
const cargoQuery = useMemo<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,
});
// 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<ContainerLineDraft>) =>
setContainerLines((prev) =>
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
);
const patchUnit = (
lineIdx: number,
unitIdx: number,
patch: Partial<UnitDraft>,
) =>
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<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,
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<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 [];
// A line can be 0 (the contract covers both sizes; a booking may only need
// one) but the booking as a whole needs at least one container — anchor
// that error on the first line's quantity so it renders in the field.
const totalQty = containerLines.reduce(
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
0,
);
return containerLines.map((line, idx) => {
const errs: LineErrors = {};
const qty = Number(line.quantity || 0);
if (line.quantity.trim() === "") {
errs.quantity = "Quantity is required.";
} else if (Number.isNaN(qty) || qty < 0) {
errs.quantity = "Enter 0 or more.";
} else if (idx === 0 && totalQty < 1) {
errs.quantity = "Book at least one container (either size).";
} else if (qty >= 1 && line.units.length < qty) {
errs.units = `Enter details for all ${qty} container(s).`;
}
if (contract.isHazardous) {
const h = Number(line.hazardousQuantity || 0);
if (Number.isNaN(h) || h < 0) {
errs.hazardousQuantity = "Enter a valid hazardous quantity.";
} else if (h > qty) {
errs.hazardousQuantity = `Can't exceed the ${qty} container(s) in this line.`;
}
}
if (contract.isReefer) {
const r = Number(line.reeferQuantity || 0);
if (Number.isNaN(r) || r < 0) {
errs.reeferQuantity = "Enter a valid refrigerated quantity.";
} else if (r > qty) {
errs.reeferQuantity = `Can't exceed the ${qty} container(s) in this line.`;
}
}
if (contractWithReturn) {
const w = Number(line.returnQuantity || 0);
if (Number.isNaN(w) || w < 0) {
errs.returnQuantity = "Enter a valid return quantity.";
} else if (w > qty) {
errs.returnQuantity = `Can't exceed the ${qty} container(s) in this line.`;
}
}
return errs;
});
}, [isContainer, contract, containerLines, contractWithReturn]);
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server rejects it too (the price
// modal's `pairingErrors`), but that only lands after GL has filled the whole
// form — mirror the customer portal (new-booking-form/schema.ts `calcWagons`)
// and block it inline instead. Size strings arrive as "20ft" from the contract
// scope but as a bare "20" from the rebook seed, so match on the leading digits.
const ft20Total = useMemo(() => {
if (!isContainer) return 0;
return containerLines
.filter((l) => parseInt(l.containerSize, 10) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
}, [isContainer, containerLines]);
const hasOdd20ft = ft20Total % 2 === 1;
// Only a customs (Path B) instance being COMPLETED by GL can use the shared
// wagon: it is GL, not the customer, who links the two bookings. Anything else
// falls through to the server's automatic consolidation gate.
const oddConsolidationAvailable = Boolean(
completeBookingId && isContainer && contract?.customsClearingEnabled,
);
// Auto-on: entering an odd 20ft total opens the consolidation panel by itself,
// once. GL can still switch it off — then odd is blocked exactly as before.
useEffect(() => {
if (!oddConsolidationAvailable) return;
if (consolidateTouchedRef.current) return;
if (hasOdd20ft) setConsolidateOdd(true);
}, [oddConsolidationAvailable, hasOdd20ft]);
// Clear the partner as soon as the panel closes or stops applying, so a
// leftover selection can never ride along into a plain single-booking submit.
useEffect(() => {
if (consolidateOdd && oddConsolidationAvailable) return;
setPartner(null);
setPartnerLines([]);
setPartnerCargoDescription("");
}, [consolidateOdd, oddConsolidationAvailable]);
const consolidationActive =
oddConsolidationAvailable && consolidateOdd && hasOdd20ft;
// Once a parent booking is linked, each booking's cargo is entered under its
// own labelled heading so it is clear which containers belong to whom.
const splitView = Boolean(consolidationActive && partner);
const candidatesQuery = useQuery({
queryKey: ["consolidation-candidates", id, completeBookingId],
queryFn: () =>
contractsService.listConsolidationCandidates(
id ?? "",
completeBookingId ?? "",
),
enabled:
partnerPickerOpen && Boolean(id) && Boolean(completeBookingId),
});
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
const bulkErrors = useMemo<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 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;
// COMPLETION never blocks on an odd 20ft total: a customs instance can share
// the wagon via the manual pair (consolidationActive), and anything else is
// auto-paired or parked as PENDING_CONSOLIDATION by the server's
// consolidation gate. Creating a booking from scratch keeps the block.
const oddBlocksSubmit = hasOdd20ft && !completeBookingId;
// Partner side: a linked partner must be picked, carry an odd 20ft count of
// its own (odd + odd = even fills the wagon) and have complete unit details.
const partnerFt20Total = useMemo(() => {
if (!consolidationActive) return 0;
return partnerLines
.filter((l) => parseInt(l.containerSize, 10) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
}, [consolidationActive, partnerLines]);
const partnerError = useMemo<string | undefined>(() => {
if (!consolidationActive) return undefined;
if (!partner) return "Select the booking that shares this wagon.";
const totalQty = partnerLines.reduce(
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
0,
);
if (totalQty < 1) {
return `Enter the containers for ${partner.reference}.`;
}
if (partnerFt20Total % 2 === 0) {
return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`;
}
const incomplete = partnerLines.some((line) => {
const qty = Number(line.quantity || 0);
return qty >= 1 && line.units.length < qty;
});
if (incomplete) {
return `Enter the container details for all of ${partner.reference}'s containers.`;
}
const badUnit = partnerLines.some((line) =>
line.units.some(
(u) =>
!ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) ||
!(Number(u.vgmTons) > 0),
),
);
if (badUnit) {
return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`;
}
if (!partnerCargoDescription.trim()) {
return `Describe the cargo carried in ${partner.reference}'s containers.`;
}
return undefined;
}, [
consolidationActive,
partner,
partnerLines,
partnerFt20Total,
partnerCargoDescription,
]);
// Only IMPORT actually chooses — the rest bill ETB regardless of the state.
const effectiveCurrency: "USD" | "ETB" =
isImport && paymentCurrency ? paymentCurrency : "ETB";
const currencyError =
isImport && !paymentCurrency
? "Select the billing currency for this booking."
: undefined;
const formValid =
cargoValid &&
!oddBlocksSubmit &&
!dateError &&
!routeError &&
!partnerError &&
!currencyError;
/** 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: effectiveCurrency,
// Intercity bookings carry no date — staff assign a passing train later.
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
// EXPORT rail: lock the booking onto the picked train.
...(trainScheduleId ? { trainScheduleId } : {}),
...(notes.trim() ? { notes: notes.trim() } : {}),
// Equipment return: WITH_RETURN contracts derive it server-side from the
// per-line return quantities; only legacy contracts (no value chosen at
// creation) still send the booking-level toggle. Bulk keeps the default.
...(legacyReturnToggle
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
: {}),
};
if (isContainer) {
// What the containers carry — captured per booking, not on the contract.
if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim();
payload.containers = containerLines
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
...(contractWithReturn
? { returnQuantity: Number(l.returnQuantity || 0) }
: {}),
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
// Per-container handling — the server rolls these into the line
// counts and bills each surcharge on the ticked containers only.
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
...(contractWithReturn ? { isReturn: Boolean(u.isReturn) } : {}),
})),
}));
} else {
payload.bulkLines = [
{
cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
...(bulk.cargoWeightTons !== ""
? { cargoWeightTons: Number(bulk.cargoWeightTons) }
: {}),
...(bulk.itemCount !== "" ? { itemCount: Number(bulk.itemCount) } : {}),
hazardousQuantity: Number(bulk.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(bulk.reeferQuantity || 0) || undefined,
},
];
}
return payload;
};
/**
* Completion DTO for the partner half of a shared wagon. Route, day and train
* are deliberately copied from THIS booking: the two bookings ride the same
* wagon, so they must ride the same train on the same day. Only the cargo and
* the billing currency belong to the partner.
*/
const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => {
if (!partner || !consolidationActive) return null;
const payload: Freight.CreateBookingUnderContractDto = {
paymentCurrency: effectiveCurrency,
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
...(trainScheduleId ? { trainScheduleId } : {}),
...(partnerCargoDescription.trim()
? { cargoFreeText: partnerCargoDescription.trim() }
: {}),
containers: partnerLines
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
})),
})),
};
return payload;
};
// Authoritative price preview (same pricing pass the booking persists at
// create): rail freight + first/last mile + overweight + every surcharge,
// plus the hard-block checks (20ft pairing, max capacity, container numbers
// already booked on the same train). Fired when the price modal opens; the
// modal falls back to the contract unit-rate estimate while it loads.
const validateShipmentMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
contractsService.validateShipment(id ?? "", dto, completeBookingId),
});
const validation = validateShipmentMutation.data ?? null;
// The partner is priced against ITS OWN contract, so the two totals shown in
// the confirm modal are each customer's real bill — nobody pays for the other.
const validatePartnerMutation = useMutation({
mutationFn: (input: {
contractId: string;
bookingId: string;
dto: Freight.CreateBookingUnderContractDto;
}) =>
contractsService.validateShipment(
input.contractId,
input.dto,
input.bookingId,
),
});
const partnerValidation = validatePartnerMutation.data ?? null;
const serverTotal = useMemo(() => {
const items = validation?.lineItems;
if (!items?.length) return null;
return {
currency: validation?.currency ?? priceTotal?.currency ?? "ETB",
lines: items.map((li) => ({
label: li.description,
unitPrice: li.unitAmount,
unit: li.unit.toLowerCase(),
quantity: li.quantity,
amount: li.amount,
})),
total:
validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [validation, priceTotal]);
const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0;
const pairingErrors = validation?.pairingErrors ?? [];
const capacityErrors = validation?.capacityErrors ?? [];
const containerClashErrors = validation?.containerClashErrors ?? [];
const spaceErrors = validation?.spaceErrors ?? [];
const overweightLines = validation?.overweightLines ?? [];
// Fallback while the server preview loads: the contract's frozen unit rates
// with the overweight surcharge folded in — same fallback the portal shows.
const displayTotal = useMemo(() => {
if (serverTotal) return serverTotal;
if (!priceTotal) return null;
if (!(overweightSurchargeAmount > 0)) return priceTotal;
return {
...priceTotal,
lines: [
...priceTotal.lines,
{
label: "Overweight surcharge",
unitPrice: overweightSurchargeAmount,
unit: "flat" as const,
quantity: 1,
amount: overweightSurchargeAmount,
},
],
total: priceTotal.total + overweightSurchargeAmount,
};
}, [serverTotal, priceTotal, overweightSurchargeAmount]);
const partnerTotal = useMemo(() => {
const items = partnerValidation?.lineItems;
if (!items?.length) return null;
return {
currency: partnerValidation?.currency ?? "ETB",
lines: items.map((li) => ({
label: li.description,
unitPrice: li.unitAmount,
unit: li.unit.toLowerCase(),
quantity: li.quantity,
amount: li.amount,
})),
total:
partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [partnerValidation]);
// The partner half must clear the same hard blocks as this one — the pair is
// booked all-or-nothing, so a block on either side blocks both.
const partnerBlockers = useMemo(() => {
if (!consolidationActive || !partnerValidation) return [];
return [
...(partnerValidation.pairingErrors ?? []),
...(partnerValidation.capacityErrors ?? []),
...(partnerValidation.containerClashErrors ?? []),
...(partnerValidation.spaceErrors ?? []),
];
}, [consolidationActive, partnerValidation]);
const completePairMutation = useMutation({
mutationFn: (input: {
payload: Freight.CreateBookingUnderContractDto;
partnerPayload: Freight.CreateBookingUnderContractDto;
partnerBookingId: string;
}) =>
contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", {
partnerBookingId: input.partnerBookingId,
booking: input.payload,
partner: input.partnerPayload,
}),
});
const submitPending =
mutations.createBooking.isPending ||
mutations.completeBooking.isPending ||
completePairMutation.isPending;
// Block confirm until the authoritative server price is in hand — the client
// estimate is display-only; booking on it would confirm an un-validated,
// possibly wrong price. Same guard as the portal form.
const confirmDisabled =
submitPending ||
validateShipmentMutation.isPending ||
pairingErrors.length > 0 ||
capacityErrors.length > 0 ||
containerClashErrors.length > 0 ||
spaceErrors.length > 0 ||
!serverTotal ||
// Same bar for the shared-wagon partner: its authoritative price must be in
// hand and its own hard blocks clear before either booking is confirmed.
(consolidationActive &&
(validatePartnerMutation.isPending ||
!partnerTotal ||
partnerBlockers.length > 0));
const openPriceModal = () => {
// Surface the per-field errors (portal-parity validation) instead of
// sending an invalid payload to the price preview.
if (!formValid) {
setShowErrors(true);
return;
}
setShowErrors(false);
setPriceOpen(true);
const payload = buildPayload();
if (payload) {
validateShipmentMutation.reset();
validateShipmentMutation.mutate(payload);
}
validatePartnerMutation.reset();
const partnerPayload = buildPartnerPayload();
if (partnerPayload && partner?.contractId) {
validatePartnerMutation.mutate({
contractId: partner.contractId,
bookingId: partner.id,
dto: partnerPayload,
});
}
};
const handleSubmit = () => {
if (!contract || !formValid) return;
// Never book past unresolved 20ft pairing hard-blocks.
if (pairingErrors.length > 0) return;
// A line above the container type's max capacity can never book.
if (capacityErrors.length > 0) return;
// A container already on another booking of the same train can never book.
if (containerClashErrors.length > 0) return;
// An export booking no open train can carry whole can never book.
if (spaceErrors.length > 0) return;
const payload = buildPayload();
if (!payload) return;
// Shared wagon: both halves complete together, all-or-nothing on the server.
if (consolidationActive && partner && completeBookingId) {
// A hard block on the partner's own price preview blocks the pair.
if (partnerBlockers.length > 0) return;
const partnerPayload = buildPartnerPayload();
if (!partnerPayload) return;
completePairMutation.mutate(
{
payload,
partnerPayload,
partnerBookingId: partner.id,
},
{
onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`),
},
);
return;
}
if (completeBookingId) {
// Completion mode: cargo + day land on the already-cleared instance —
// the request was linked and accepted at submission time.
mutations.completeBooking.mutate(
{ bookingId: completeBookingId, payload },
{
onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`),
},
);
return;
}
mutations.createBooking.mutate(payload, {
onSuccess: async (booking) => {
if (requestId) {
try {
await contractsService.acceptBookingRequest(requestId, booking.id);
} catch {
// Non-fatal
}
}
// Clearance is always per booking — land on that booking's clearance
// detail, the same page the hub opens.
navigate(`/dashboard/clearance/${booking.id}`);
},
});
};
if (isLoading || windowsLoading) {
return (
<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(
completeBookingId
? `/dashboard/clearance/${completeBookingId}`
: "/dashboard/contracts/clearance",
)
}
>
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 />
<Textarea
label="Cargo description *"
description="What do the containers carry on this shipment?"
placeholder="e.g. Electronics, garments, machinery spare parts…"
value={cargoDescription}
onChange={(e) => setCargoDescription(e.currentTarget.value)}
error={showErrors ? cargoDescriptionError : undefined}
radius={10}
autosize
minRows={2}
maxRows={4}
styles={fieldStyles}
/>
{/* With a parent booking linked, each booking's containers are
entered in its own labelled section, one after the other. */}
{splitView ? (
<Group gap={8} align="center">
<Badge color="edr-green" variant="light" radius="sm">
{completeBooking?.reference ?? "This booking"}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{completeBooking?.company?.name ?? "—"}
</Text>
</Group>
) : null}
{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={0}
value={line.quantity}
error={
showErrors
? (lineErrors[lineIdx]?.quantity ??
lineErrors[lineIdx]?.units)
: undefined
}
onChange={(e) => {
patchLine(lineIdx, { quantity: e.currentTarget.value });
}}
onBlur={(e) => {
syncUnits(lineIdx, Number(e.currentTarget.value || 0));
}}
radius={10}
styles={fieldStyles}
/>
</Group>
<StepLabel>Per-container details</StepLabel>
{handlingColumns.length > 0 ? (
<Text fz={11} c="dimmed" mt={4}>
Tick the services each individual container needs
charges apply only to the containers ticked
{handlingColumns
.map((col) => {
const count = line.units.filter(
(u) => u[col.key],
).length;
return count > 0 ? ` · ${count} ${col.label.toLowerCase()}` : "";
})
.join("")}
.
</Text>
) : null}
<Stack gap={10} mt={8}>
{/* Header row — input labels + handling-service labels,
one aligned grid shared by every unit row below.
Same layout as the portal shipment form. */}
{line.units.length > 0 && (
<Group gap={10} wrap="nowrap" align="flex-end">
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
</Text>
{handlingColumns.map((col) => (
<Group
key={col.key}
gap={4}
wrap="nowrap"
justify="center"
style={{ width: 96, flexShrink: 0 }}
>
<span style={{ color: col.color, display: "flex" }}>
{col.icon}
</span>
<Text fz={12} fw={600} c="#10202F">
{col.label}
</Text>
</Group>
))}
</Group>
)}
{line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} wrap="nowrap" align="flex-start">
<TextInput
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}
style={{ flex: 1 }}
/>
<TextInput
placeholder="Optional"
value={unit.sealNumber}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<TextInput
type="number"
onKeyDown={blockNegative}
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}
style={{ flex: 1 }}
/>
{handlingColumns.map((col) => (
<Box
key={col.key}
style={{
width: 96,
flexShrink: 0,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Switch
checked={Boolean(unit[col.key])}
aria-label={`${col.label} — container ${unitIdx + 1}`}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
[col.key]: e.currentTarget.checked,
})
}
size="sm"
/>
</Box>
))}
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove container ${unitIdx + 1}`}
onClick={() => removeUnit(lineIdx, unitIdx)}
>
<X size={16} />
</ActionIcon>
</Group>
))}
</Stack>
</Box>
))
)}
{hasOdd20ft && oddConsolidationAvailable ? (
<Alert
color={consolidateOdd ? "edr-green" : "red"}
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Total})`}
>
<Stack gap={10}>
<Text fz={13}>
20ft containers travel two per wagon, so one container here
is unpaired. On a customs booking you can pair it with
another customer's odd booking and complete both onto the
shared wagon — each booking is still priced and invoiced
separately.
</Text>
<Switch
checked={consolidateOdd}
color="edr-green"
label="Share a wagon with another booking"
onChange={(e) => {
consolidateTouchedRef.current = true;
setConsolidateOdd(e.currentTarget.checked);
}}
/>
{consolidateOdd ? (
<Group gap={10} align="center" wrap="wrap">
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
leftSection={<Link2 size={14} />}
onClick={() => setPartnerPickerOpen(true)}
>
{partner
? `Parent booking: ${partner.reference} — change`
: "Parent booking"}
</Button>
{partner ? (
<Button
size="xs"
radius="md"
variant="subtle"
color="gray"
onClick={() => {
setPartner(null);
setPartnerLines([]);
setPartnerCargoDescription("");
}}
>
Remove
</Button>
) : null}
</Group>
) : (
<Text fz={12.5} c="red.7">
With sharing off, book an even number of 20ft containers
— add one more or remove one (e.g. {ft20Total + 1} or{" "}
{ft20Total - 1} instead of {ft20Total}).
</Text>
)}
</Stack>
</Alert>
) : hasOdd20ft ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Total})`}
>
20ft containers travel two per wagon, so they must be booked in
even numbers. Add one more 20ft container or remove one (e.g.
book {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total})
— the booking cannot be created with an unpaired 20ft container.
</Alert>
) : null}
{splitView && partner ? (
<>
<Divider my={4} />
<Group gap={8} align="center">
<Badge color="blue" variant="light" radius="sm">
{partner.reference}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{partner.companyName ?? "—"}
</Text>
</Group>
<Text fz={12.5} c="dimmed">
Parent booking — ships on the same day and train, billed to
its own customer.
</Text>
<ConsolidationPartnerPanel
lines={partnerLines}
onLinesChange={setPartnerLines}
cargoDescription={partnerCargoDescription}
onCargoDescriptionChange={setPartnerCargoDescription}
showHazardous={Boolean(contract.isHazardous)}
showReefer={Boolean(contract.isReefer)}
showErrors={showErrors}
error={partnerError}
/>
</>
) : null}
</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>
)}
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
return quantities above, WITHOUT_RETURN contracts locked it off. */}
{legacyReturnToggle ? (
<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."
/>
<Box>
<StepLabel>Shipment day *</StepLabel>
<Box mt={10} w="100%">
{/* Calendar stays visible before cargo is entered — all days
disabled with a hint, since availability depends on cargo. */}
<OperationDatePicker
fullWidth
availableDays={cargoQuery === null ? [] : (availableDays ?? [])}
isLoading={cargoQuery !== null && daysLoading}
emptyMessage={
cargoQuery === null
? "Enter the cargo details first — available shipment days depend on the wagons the cargo needs."
: undefined
}
value={scheduledDate}
onChange={(d) => {
setScheduledDate(d);
// A new day invalidates the old train pick.
setTrainScheduleId("");
}}
/>
</Box>
{showErrors && dateError && (
<Text fz="xs" c="red" mt={6}>
{dateError}
</Text>
)}
{isExportPick && scheduledDate ? (
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={trainScheduleId}
onChange={setTrainScheduleId}
/>
) : null}
</Box>
</StepCard>
)}
<StepCard>
<Box mb="md">
<Text size="sm" fw={600} mb={4}>
Billing currency
</Text>
<Text size="xs" c="dimmed" mb={8}>
{isImport
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
: "Shipments are invoiced in ETB."}
</Text>
<CurrencySelector
value={isImport ? paymentCurrency : "ETB"}
onChange={setPaymentCurrency}
disabled={!isImport}
allowUsd={isImport}
error={currencyError}
/>
</Box>
<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">
{/* The review button is disabled on an odd 20ft total, so the click
that would surface the errors never lands — state the reason here
rather than leaving it in a tooltip nobody hovers. */}
{oddBlocksSubmit ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
title={`Odd number of 20ft containers (${ft20Total})`}
>
20ft containers travel two per wagon, so they must be booked in
even numbers. Add one more 20ft container or remove one — book{" "}
{ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}.
</Alert>
) : showErrors && !formValid ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted fields before reviewing the price.
</Alert>
) : partnerError ? (
// The review button is disabled while the parent booking is
// incomplete, so the click that would reveal the errors never
// lands — say what is outstanding without waiting for it.
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
{partnerError}
</Alert>
) : null}
<Group justify="flex-end">
<Tooltip
label={
oddBlocksSubmit
? `Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`
: (partnerError ?? "")
}
withArrow
// Only explain a block that is actually in force: an odd count
// linked to a parent booking is resolved by the shared wagon.
disabled={!oddBlocksSubmit && !partnerError}
>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
// An unpaired 20ft can never be planned onto a wagon — unless
// a parent booking is linked to share it, which is what
// oddBlocksSubmit accounts for. The parent's own cargo must be
// complete too, or there is nothing to price.
disabled={oddBlocksSubmit || Boolean(partnerError)}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</Box>
<ConsolidationPartnerPicker
opened={partnerPickerOpen}
onClose={() => setPartnerPickerOpen(false)}
candidates={candidatesQuery.data ?? []}
isLoading={candidatesQuery.isLoading}
isError={candidatesQuery.isError}
onSelect={(candidate) => {
setPartner(candidate);
// Seed a 20ft and a 40ft line. The parent booking sits on its OWN
// contract, whose size scope need not match this one's, so the panel
// offers both sizes rather than mirroring this contract's scope; a
// size the parent does not ship is simply left at 0.
setPartnerLines(PARTNER_SIZES.map(emptyPartnerLine));
setPartnerCargoDescription("");
setPartnerPickerOpen(false);
}}
/>
<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.containerLabel || 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" }}>
{/* Whose bill this is. Only worth naming when a second booking is
on screen — on a lone booking there is nothing to confuse it with. */}
{consolidationActive && partner ? (
<Group gap={8} align="center" mb={12} wrap="wrap">
<Badge color="edr-green" variant="light" radius="sm">
{completeBooking?.reference ?? "This booking"}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{completeBooking?.company?.name ?? contract.company?.name ?? "—"}
</Text>
</Group>
) : null}
<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>
{consolidationActive && partner ? (
<Paper
withBorder
radius={16}
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Group gap={8} align="center" mb={12} wrap="wrap">
<Badge color="blue" variant="light" radius="sm">
{partner.reference}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{partner.companyName ?? "—"}
</Text>
</Group>
{validatePartnerMutation.isPending ? (
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Pricing the partner booking
</Text>
</Group>
) : partnerBlockers.length > 0 ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Cannot book ${partner.reference}`}
>
<Stack gap={6}>
{partnerBlockers.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Both bookings are confirmed together, so this must be
fixed before either can be booked.
</Text>
</Stack>
</Alert>
) : partnerTotal ? (
<>
<Stack gap={10}>
{partnerTotal.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()}{" "}
{partnerTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text
fz="sm"
fw={600}
style={{ whiteSpace: "nowrap" }}
>
{line.amount.toLocaleString()}{" "}
{partnerTotal.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="blue"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28}>
{partnerTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{partnerTotal.currency}
</Text>
</Text>
</Group>
</>
) : (
<Text fz="sm" c="dimmed">
No price yet for the partner booking.
</Text>
)}
</Paper>
) : null}
{consolidationActive && partner ? (
<Alert
color="blue"
variant="light"
radius="md"
icon={<Link2 size={16} />}
>
<Text fz="sm">
These two bookings share one wagon but stay separate: each is
invoiced to its own customer and paid separately. Confirming
books both together if either fails, neither is booked.
</Text>
</Alert>
) : null}
<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}
>
{consolidationActive && partner
? "Confirm & book both"
: completeBookingId
? "Confirm & complete"
: "Confirm & book"}
</Button>
</Group>
</Stack>
) : null}
</Modal>
</PageContainer>
);
}