mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component. - Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel. - Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains. - Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages. - Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking. - Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings. - Added a new reference field to the audit logs for better searchability and tracking of actions. - Created a migration to add the reference column to the audit logs table and established an index for efficient querying. - Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities.
2708 lines
102 KiB
TypeScript
2708 lines
102 KiB
TypeScript
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,
|
||
emptyPartnerUnit,
|
||
} 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 shipment request is the customer's order: container sizes/quantities
|
||
// and the billing currency are the customer's choices and stay read-only —
|
||
// GL enters only per-unit details (numbers, seals, VGM, handling). The
|
||
// server enforces the same on completion.
|
||
const requestContainersLocked = Boolean(
|
||
bookingRequest?.requestedLines?.containers?.length,
|
||
);
|
||
const requestBulkLocked =
|
||
bookingRequest?.requestedLines?.bulk?.cargoWeightTons != null;
|
||
const requestCurrencyLocked = Boolean(bookingRequest?.paymentCurrency);
|
||
|
||
// 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("");
|
||
|
||
// The partner is its own customer: if a shipment request created it, that
|
||
// request locks the partner's quantities and billing currency the same way
|
||
// this booking's request locks this side (server enforces both halves).
|
||
const { data: partnerContractRequests } = useQuery({
|
||
queryKey: ["shipment-requests-for-contract", partner?.contractId],
|
||
queryFn: () => contractsService.listBookingRequests(partner!.contractId!),
|
||
enabled: Boolean(partner?.contractId),
|
||
});
|
||
const partnerRequest =
|
||
(partner &&
|
||
partnerContractRequests?.find(
|
||
(r) => r.createdBookingId === partner.id,
|
||
)) ||
|
||
null;
|
||
const partnerLocked = Boolean(partnerRequest?.requestedLines?.containers?.length);
|
||
|
||
// Seed (and lock) the partner's lines from its request once it loads.
|
||
useEffect(() => {
|
||
const requested = partnerRequest?.requestedLines?.containers;
|
||
if (!partner || !requested?.length) return;
|
||
setPartnerLines(
|
||
requested.map((c) => ({
|
||
containerSize: c.containerSize,
|
||
quantity: String(Math.max(1, c.quantity)),
|
||
hazardousQuantity: "0",
|
||
reeferQuantity: "0",
|
||
returnQuantity: "0",
|
||
units: Array.from({ length: Math.max(1, c.quantity) }, emptyPartnerUnit),
|
||
})),
|
||
);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [partner?.id, partnerRequest?.id]);
|
||
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);
|
||
// Currency is the customer's choice on the request — seed it here; the
|
||
// selector below is disabled while the request specifies one.
|
||
if (
|
||
bookingRequest.paymentCurrency === "USD" ||
|
||
bookingRequest.paymentCurrency === "ETB"
|
||
) {
|
||
setPaymentCurrency(bookingRequest.paymentCurrency);
|
||
}
|
||
}, [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 = {
|
||
// The partner's customer chose its own currency on its shipment request;
|
||
// only a partner without a request falls back to this booking's currency.
|
||
paymentCurrency:
|
||
partnerRequest?.paymentCurrency === "USD" ||
|
||
partnerRequest?.paymentCurrency === "ETB"
|
||
? partnerRequest.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}
|
||
disabled={requestContainersLocked}
|
||
description={
|
||
requestContainersLocked
|
||
? "Requested by the customer — quantity cannot be changed."
|
||
: undefined
|
||
}
|
||
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}
|
||
lockQuantities={partnerLocked}
|
||
/>
|
||
</>
|
||
) : 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}
|
||
disabled={requestBulkLocked}
|
||
description={
|
||
requestBulkLocked
|
||
? "Requested by the customer — quantity cannot be changed."
|
||
: undefined
|
||
}
|
||
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}>
|
||
{requestCurrencyLocked
|
||
? "The customer chose the billing currency on the shipment request — it cannot be changed."
|
||
: 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 || requestCurrencyLocked}
|
||
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 & 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 & 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>
|
||
);
|
||
}
|