Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
Marshal 1abef3ce34 enhance shipment requests page with filtering and sorting options
- Added status and cargo filters to the ShipmentRequestsPage.
- Implemented date range filtering for preferred dates.
- Introduced sorting options for shipment requests based on submission date and reference.
- Enhanced the display of shipment request details, including status badges and customer information.
- Updated the UI to include a search input with clear functionality and improved layout for filters.

feat: add equipment return option in new shipment form

- Introduced a toggle for equipment return in the NewShipmentPage.
- Updated form schema to include  field for container contracts.
- Enhanced user experience with visual feedback on the equipment return selection.

fix: update booking DTO to include equipment return option

- Added  field to CreateBookingUnderContractDto for per-shipment override.
- Updated related types and schemas to accommodate the new field for better contract handling.
2026-07-10 10:32:41 +00:00

1443 lines
50 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 } from "react";
import {
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Center,
Divider,
FileButton,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Switch,
Text,
Textarea,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
FileDown,
FileText,
FileUp,
MapPin,
Package,
Receipt,
Repeat,
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service";
import {
useContractDetail,
useContractMutations,
} from "@/hooks/contracts/useContracts";
import {
computeGlShipmentTotal,
formatRateUnit,
type GlShipmentQuantities,
} from "./gl-booking-form/total";
import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice";
import {
downloadContainerImportTemplate,
parseContainerExcel,
} from "./gl-booking-form/container-excel";
import {
fieldStyles,
StepCard,
StepHeader,
StepLabel,
} from "./gl-booking-form/form-ui";
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
// ISO 6346: 4-letter owner/category code + 6-digit serial + check digit.
// Same rule the customer portal shipment form enforces.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
interface UnitErrors {
containerNumber?: string;
vgmTons?: 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: number | string;
/** Per-unit flags — the line's hazardous/reefer counts are derived from these. */
hazardous: boolean;
reefer: boolean;
}
interface ContainerLineDraft {
containerSize: string;
units: UnitDraft[];
}
interface BulkLineDraft {
cargoTypeId: string;
cargoWeightTons: number | string;
itemCount: number | string;
hazardousQuantity: number | string;
reeferQuantity: number | string;
}
function emptyUnit(): UnitDraft {
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
hazardous: false,
reefer: false,
};
}
function bulkUnitOfMeasure(
contract: Freight.IContract,
): "PER_TON" | "PER_ITEM" {
const hasPerItem = contract.pricingBreakdown?.lineItems?.some(
(li) => li.unit === "per_item",
);
return hasPerItem ? "PER_ITEM" : "PER_TON";
}
export default function GlCreateBookingForm() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const requestId = searchParams.get("requestId");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
const { data: bookingRequest } = useQuery({
queryKey: ["shipment-request", requestId],
queryFn: () => contractsService.getBookingRequest(requestId!),
enabled: Boolean(requestId),
});
// Same window-gating the customer sees: GL may only create a booking while a
// booking window is OPEN for one of the contract's routes.
const contractId = contract?.id ?? id;
const { data: bookingWindows, isLoading: windowsLoading } = useQuery({
...api.trainScheduling.contractBookingWindows.queryOptions({
input: { contractId: contractId ?? "" },
}),
enabled: Boolean(contractId),
});
const windowOpen = useMemo(
() => (bookingWindows ?? []).some((w) => w.isOpenNow),
[bookingWindows],
);
// Next future window across all routes, used for the "next window" notice —
// the train dispatching soonest among those not yet open, matching the
// departure-date ordering of the window cards.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort((a, b) => {
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
if (da !== db) return da - db;
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
})[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
const [withReturn, setWithReturn] = useState(false);
const [prefilled, setPrefilled] = useState(false);
const [priceOpen, setPriceOpen] = useState(false);
const seededRef = useRef(false);
const returnSeededRef = useRef(false);
// Seed the equipment-return toggle from the contract exactly once (also when
// the form is prefilled from a shipment request); GL can flip it per shipment.
useEffect(() => {
if (!contract || returnSeededRef.current) return;
returnSeededRef.current = true;
setWithReturn(contract.equipmentReturn === "WITH_RETURN");
}, [contract]);
const isContainer = contract?.freightType === "CONTAINER";
const routes = useMemo(
() =>
[...(contract?.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder),
[contract?.routes],
);
const needsRouteSelect = contract?.contractKind === "GENERAL" && routes.length > 1;
const containerSizes = useMemo(() => {
const sizes = new Set<string>();
(contract?.cargoScope ?? []).forEach((s) => {
if (s.containerSize) sizes.add(s.containerSize);
});
return [...sizes];
}, [contract?.cargoScope]);
const bulkCargoOptions = useMemo(() => {
const seen = new Map<string, string>();
(contract?.cargoScope ?? []).forEach((s) => {
if (s.containerSize || !s.cargoTypeId) return;
if (!seen.has(s.cargoTypeId)) {
seen.set(s.cargoTypeId, s.cargoFreeText?.trim() || s.cargoTypeId);
}
});
return [...seen.entries()].map(([value, label]) => ({ value, label }));
}, [contract?.cargoScope]);
const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? "";
useEffect(() => {
if (!bookingRequest || prefilled) return;
setPrefilled(true);
const lines = bookingRequest.requestedLines ?? {};
if (lines.containers?.length) {
setContainerLines(
lines.containers.map((c) => ({
containerSize: c.containerSize,
// The request carries counts; pre-toggle the first N units so GL sees
// the customer's declared hazardous/reefer split and can adjust it.
units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({
...emptyUnit(),
hazardous: i < Number(c.hazardousQuantity ?? 0),
reefer: i < Number(c.reeferQuantity ?? 0),
})),
})),
);
} else if (lines.bulk) {
setBulkLines([
{
cargoTypeId: lines.bulk.cargoTypeId ?? defaultBulkCargoTypeId,
cargoWeightTons: lines.bulk.cargoWeightTons ?? "",
itemCount: lines.bulk.itemCount ?? "",
hazardousQuantity: lines.bulk.hazardousQuantity ?? "0",
reeferQuantity: "",
},
]);
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled, defaultBulkCargoTypeId]);
useEffect(() => {
if (!contract || prefilled || seededRef.current) return;
seededRef.current = true;
if (isContainer && containerSizes.length > 0 && containerLines.length === 0) {
setContainerLines(
containerSizes.map((size) => ({
containerSize: size,
units: [emptyUnit()],
})),
);
} else if (!isContainer && bulkLines.length === 0) {
setBulkLines([
{
cargoTypeId: defaultBulkCargoTypeId,
cargoWeightTons: "",
itemCount: "",
hazardousQuantity: "0",
reeferQuantity: "0",
},
]);
}
}, [
contract,
prefilled,
isContainer,
containerSizes,
containerLines.length,
bulkLines.length,
defaultBulkCargoTypeId,
]);
const quantities: GlShipmentQuantities = useMemo(
() => ({
isContainer: Boolean(isContainer),
containers: containerLines.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
reeferQuantity: l.units.filter((u) => u.reefer).length,
})),
bulkQuantity: bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
0,
),
bulkHazardousQuantity: bulkLines.reduce(
(s, l) => s + Number(l.hazardousQuantity || 0),
0,
),
}),
[isContainer, containerLines, bulkLines],
);
const priceTotal = useMemo(
() => (contract ? computeGlShipmentTotal(contract, quantities) : null),
[contract, quantities],
);
const selectedRoute = useMemo(
() => routes.find((r) => r.id === contractRouteId) ?? routes[0],
[routes, contractRouteId],
);
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId)
return null;
if (isContainer) {
const containers = containerLines
.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
}))
.filter((c) => c.quantity >= 1);
if (containers.length === 0) return null;
return {
originYardId: selectedRoute.originYardId,
destinationYardId: selectedRoute.destinationYardId,
freightType: "CONTAINER",
containers,
};
}
const tons = bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || 0),
0,
);
if (tons <= 0) return null;
return {
originYardId: selectedRoute.originYardId,
destinationYardId: selectedRoute.destinationYardId,
freightType: "BULK",
cargoTypeCode:
contract?.pricingBreakdown?.lineItems?.find((li) => li.cargoTypeCode)
?.cargoTypeCode ?? undefined,
totalWeightTons: tons,
};
}, [selectedRoute, isContainer, containerLines, bulkLines, contract?.pricingBreakdown]);
const { data: availableDays, isLoading: daysLoading } = useQuery({
...api.trainScheduling.availableDaysForCargo.queryOptions({
input: cargoQuery ?? { freightType: "BULK" as const },
}),
enabled: cargoQuery !== null,
});
const syncUnits = (lineIdx: number, qty: number) => {
setContainerLines((prev) =>
prev.map((line, i) => {
if (i !== lineIdx) return line;
const next = [...line.units];
while (next.length < qty) next.push(emptyUnit());
next.length = Math.max(0, qty);
return { ...line, units: next };
}),
);
};
const patchLine = (idx: number, patch: Partial<ContainerLineDraft>) =>
setContainerLines((prev) =>
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
);
const patchUnit = (
lineIdx: number,
unitIdx: number,
patch: Partial<UnitDraft>,
) =>
patchLine(lineIdx, {
units: containerLines[lineIdx].units.map((u, i) =>
i === unitIdx ? { ...u, ...patch } : u,
),
});
const patchBulk = (idx: number, patch: Partial<BulkLineDraft>) =>
setBulkLines((prev) =>
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
);
// Same client-side validation as the customer portal shipment form: ISO
// container numbers (unique within the shipment) and a positive VGM per unit;
// bulk needs a positive quantity with hazardous/reefer portions bounded by it.
const [showErrors, setShowErrors] = useState(false);
// Excel import: one row per container. All-or-nothing — a file with any bad
// row is rejected with row-numbered errors so nothing is silently dropped.
const [importErrors, setImportErrors] = useState<string[]>([]);
const [importSummary, setImportSummary] = useState<string | null>(null);
const importResetRef = useRef<(() => void) | null>(null);
const excelOpts = {
allowedSizes: containerSizes,
includeHazardous: contract?.isHazardous ?? false,
includeReefer: contract?.isReefer ?? false,
};
const handleImportFile = async (file: File | null) => {
// Reset the hidden input so re-picking the same (fixed) file re-fires.
importResetRef.current?.();
if (!file) return;
const { rows, errors } = await parseContainerExcel(file, excelOpts);
if (errors.length > 0) {
setImportSummary(null);
setImportErrors(errors);
return;
}
// Replace only the lines for sizes present in the file; a contracted size
// the file omits keeps whatever was already entered for it.
setContainerLines((prev) =>
containerSizes.map((size) => {
const imported = rows.filter((r) => r.containerSize === size);
if (imported.length === 0) {
return (
prev.find((l) => l.containerSize === size) ?? {
containerSize: size,
units: [emptyUnit()],
}
);
}
return {
containerSize: size,
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: r.vgmTons,
hazardous: r.hazardous,
reefer: r.reefer,
})),
};
}),
);
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 (String(u.vgmTons).trim() === "" || Number.isNaN(vgm) || vgm <= 0) {
errs.vgmTons = "Enter a valid VGM.";
}
return errs;
}),
);
}, [isContainer, containerLines]);
const bulkErrors = useMemo<BulkErrors[]>(() => {
if (isContainer) return [];
return bulkLines.map((line) => {
const errs: BulkErrors = {};
const qty = Number(line.cargoWeightTons || line.itemCount || 0);
if (Number.isNaN(qty) || qty <= 0) {
errs.quantity = "Enter a quantity greater than 0.";
}
const h = Number(line.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(line.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, bulkLines]);
const cargoValid = isContainer
? unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
)
: bulkErrors.every((e) => !e.quantity && !e.hazardous && !e.reefer);
const canSubmit =
windowOpen &&
Boolean(scheduledDate) &&
(!needsRouteSelect || Boolean(contractRouteId)) &&
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
* exactly what gets booked. */
const buildPayload = (): Freight.CreateBookingUnderContractDto | null => {
if (!scheduledDate || !contract) return null;
const payload: Freight.CreateBookingUnderContractDto = {
scheduledDate,
...(contractRouteId ? { contractRouteId } : {}),
...(notes.trim() ? { notes: notes.trim() } : {}),
// Equipment return is a container concern — bulk keeps the contract default.
...(isContainer
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
: {}),
};
if (isContainer) {
payload.containers = containerLines
.filter((l) => l.units.length > 0)
.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
// Counts are derived from the per-unit toggles — they can never
// exceed the line quantity.
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
reeferQuantity: l.units.filter((u) => u.reefer).length,
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
})),
}));
} else {
payload.bulkLines = bulkLines.map((l) => ({
...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}),
...(l.cargoWeightTons !== ""
? { cargoWeightTons: Number(l.cargoWeightTons) }
: {}),
...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}),
...(l.hazardousQuantity !== ""
? { hazardousQuantity: Number(l.hazardousQuantity) }
: {}),
...(l.reeferQuantity !== ""
? { reeferQuantity: Number(l.reeferQuantity) }
: {}),
}));
}
return payload;
};
// Authoritative price preview (same pricing pass the booking persists at
// create): rail freight + first/last mile + overweight + every surcharge.
// Fired when the price modal opens; the modal falls back to the contract
// unit-rate estimate while it loads.
const validateShipmentMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
contractsService.validateShipment(id ?? "", dto),
});
const validation = validateShipmentMutation.data ?? null;
const serverTotal = useMemo(() => {
const items = validation?.lineItems;
if (!items?.length) return null;
return {
currency: validation?.currency ?? priceTotal?.currency ?? "ETB",
lines: items.map((li) => ({
label: li.description,
unitPrice: li.unitAmount,
unit: li.unit.toLowerCase(),
quantity: li.quantity,
amount: li.amount,
})),
total:
validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [validation, priceTotal]);
const displayTotal = serverTotal ?? priceTotal;
const pairingErrors = validation?.pairingErrors ?? [];
const capacityErrors = validation?.capacityErrors ?? [];
const overweightLines = validation?.overweightLines ?? [];
const openPriceModal = () => {
// Surface the per-field errors (portal-parity validation) instead of
// sending an invalid payload to the price preview.
if (!cargoValid) {
setShowErrors(true);
return;
}
setShowErrors(false);
setPriceOpen(true);
const payload = buildPayload();
if (payload) {
validateShipmentMutation.reset();
validateShipmentMutation.mutate(payload);
}
};
const handleSubmit = () => {
if (!contract || !windowOpen || !cargoValid) return;
// Never book past unresolved 20ft pairing hard-blocks.
if (pairingErrors.length > 0) return;
// A line above the container type's max capacity can never book.
if (capacityErrors.length > 0) return;
const payload = buildPayload();
if (!payload) return;
mutations.createBooking.mutate(payload, {
onSuccess: async (booking) => {
if (requestId) {
try {
await contractsService.acceptBookingRequest(requestId, booking.id);
} catch {
// Non-fatal
}
}
if (contract.contractKind === "GENERAL") {
// GENERAL per-booking clearance: land on the booking's clearance
// detail — the same page the Shipments tab on the hub opens.
navigate(`/dashboard/clearance/${booking.id}`);
} else {
// ONE_TIME customs keeps its clearance on the contract.
navigate(`/dashboard/contracts/clearance/${contract.id}`);
}
},
});
};
if (isLoading) {
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 bulkUom = bulkUnitOfMeasure(contract);
return (
<PageContainer>
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
<Box>
<Text fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
New Shipment Booking
</Text>
<Text size="sm" c="dimmed" mt={4}>
Book a shipment on behalf of the customer for contract {contract.reference}.
</Text>
</Box>
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Back to clearance
</Button>
</Group>
{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}
{!windowsLoading && !windowOpen ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Booking window is closed"
mb="lg"
>
GL can create a booking only while a window is open.{" "}
{nextWindow?.windowOpensAt ? (
<>
Next window: <b>{fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT</b>{" "}
for{" "}
<b>
{nextWindow.origin ?? "Origin"} {nextWindow.destination ?? "Destination"}
</b>
.
</>
) : (
<>No upcoming booking window scheduled.</>
)}
</Alert>
) : null}
{windowsLoading || windowOpen ? (
<>
<Stack gap="lg" maw={896} mx="auto">
<StepCard>
<StepHeader
icon={<MapPin size={22} />}
title="Route"
description={
needsRouteSelect
? "Choose which contracted route this shipment ships on."
: "This shipment ships on the contract's only route."
}
/>
{needsRouteSelect ? (
<Select
label="Contract route *"
placeholder="Select a route..."
value={contractRouteId}
onChange={setContractRouteId}
data={routes.map((r) => ({
value: r.id,
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"}${
r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"
}`,
}))}
required
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>
)}
<ContractCapacityNotice contractId={contract.id} isContainer />
{containerLines.length === 0 ? (
<Text fz="sm" c="dimmed">
This contract has no container sizes in scope.
</Text>
) : (
containerLines.map((line, lineIdx) => (
<Box
key={`${line.containerSize}-${lineIdx}`}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 16 }}
>
<Text fz={14} fw={700} mb={10}>
{line.containerSize} containers
</Text>
<Group gap={12} mb={12} align="flex-start">
<NumberInput
label="Quantity *"
min={1}
value={line.units.length}
onChange={(v) => syncUnits(lineIdx, Number(v) || 0)}
radius={10}
styles={fieldStyles}
w={160}
/>
{contract.isHazardous ? (
<Badge variant="light" color="red" radius="sm" mt={30}>
{line.units.filter((u) => u.hazardous).length} hazardous
</Badge>
) : null}
{contract.isReefer ? (
<Badge variant="light" color="blue" radius="sm" mt={30}>
{line.units.filter((u) => u.reefer).length} refrigerated
</Badge>
) : null}
</Group>
<StepLabel>Per-container details</StepLabel>
<Stack gap={10} mt={8}>
{line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} align="flex-start" wrap="nowrap">
<TextInput
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.containerNumber
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
containerNumber: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<TextInput
label={unitIdx === 0 ? "Seal number" : undefined}
placeholder="Optional"
value={unit.sealNumber}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<NumberInput
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5"
min={0}
decimalScale={2}
value={unit.vgmTons}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.vgmTons
: undefined
}
onChange={(v) =>
patchUnit(lineIdx, unitIdx, { vgmTons: v })
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
{/* Per-unit flags: toggle exactly the containers that are
hazardous / refrigerated; line counts derive from these. */}
{contract.isHazardous ? (
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
{unitIdx === 0 ? (
<Text fz={12} fw={600} c="#4A5A68">
Hazardous
</Text>
) : null}
<Switch
color="red"
size="sm"
mt={unitIdx === 0 ? 0 : 8}
aria-label={`Container ${unitIdx + 1} hazardous`}
checked={unit.hazardous}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
hazardous: e.currentTarget.checked,
})
}
/>
</Stack>
) : null}
{contract.isReefer ? (
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
{unitIdx === 0 ? (
<Text fz={12} fw={600} c="#4A5A68">
Reefer
</Text>
) : null}
<Switch
color="blue"
size="sm"
mt={unitIdx === 0 ? 0 : 8}
aria-label={`Container ${unitIdx + 1} refrigerated`}
checked={unit.reefer}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
reefer: e.currentTarget.checked,
})
}
/>
</Stack>
) : null}
</Group>
))}
</Stack>
</Box>
))
)}
</Stack>
</StepCard>
) : (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Enter the amount you are shipping for this booking."
/>
<Stack gap={14}>
<ContractCapacityNotice contractId={contract.id} isContainer={false} />
{bulkLines.map((line, idx) => (
<Stack key={idx} gap={14}>
{bulkCargoOptions.length > 0 ? (
<Select
label="Cargo type"
placeholder="Select cargo type"
value={line.cargoTypeId || null}
onChange={(v) => patchBulk(idx, { cargoTypeId: v ?? "" })}
data={bulkCargoOptions}
radius={10}
styles={fieldStyles}
/>
) : null}
{bulkUom === "PER_TON" ? (
<NumberInput
label="Quantity (tons) *"
placeholder="e.g. 1200"
min={0}
decimalScale={2}
value={line.cargoWeightTons}
error={showErrors ? bulkErrors[idx]?.quantity : undefined}
onChange={(v) => patchBulk(idx, { cargoWeightTons: v })}
radius={10}
styles={fieldStyles}
/>
) : (
<NumberInput
label="Item count *"
placeholder="e.g. 500"
min={0}
value={line.itemCount}
error={showErrors ? bulkErrors[idx]?.quantity : undefined}
onChange={(v) => patchBulk(idx, { itemCount: v })}
radius={10}
styles={fieldStyles}
/>
)}
{contract.isHazardous ? (
<NumberInput
label="Hazardous quantity"
min={0}
value={line.hazardousQuantity}
error={showErrors ? bulkErrors[idx]?.hazardous : undefined}
onChange={(v) => patchBulk(idx, { hazardousQuantity: v })}
radius={10}
styles={fieldStyles}
/>
) : null}
{contract.isReefer ? (
<NumberInput
label="Refrigerated quantity"
min={0}
value={line.reeferQuantity}
error={showErrors ? bulkErrors[idx]?.reefer : undefined}
onChange={(v) => patchBulk(idx, { reeferQuantity: v })}
radius={10}
styles={fieldStyles}
/>
) : null}
</Stack>
))}
</Stack>
</StepCard>
)}
{isContainer ? (
<StepCard>
<StepHeader
icon={<Repeat size={22} />}
title="Equipment Return"
description="Choose whether the empty container(s) come back to EDR after unloading."
/>
<Paper
withBorder
radius="md"
p="md"
style={{
borderColor: withReturn ? "#CDEBDD" : "#E6ECF2",
background: withReturn ? "#F6FBF8" : "white",
cursor: "pointer",
transition: "border-color 150ms ease, background 150ms ease",
}}
onClick={() => setWithReturn((v) => !v)}
>
<Group justify="space-between" wrap="nowrap" gap="md">
<Group gap={13} wrap="nowrap" align="flex-start">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: withReturn ? "#ECF6F1" : "#F1F4F7",
color: withReturn ? "#0A6F4D" : "#6B7C8E",
}}
>
<Repeat size={18} />
</Box>
<Box>
<Text fz={14} fw={700}>
With return
</Text>
<Text fz={12} c="dimmed" style={{ lineHeight: 1.4 }}>
{withReturn
? "Container(s) returned to EDR after unloading."
: "Container(s) retained by the customer after delivery."}
</Text>
</Box>
</Group>
<Switch
size="md"
color="edr-green"
aria-label="With return"
checked={withReturn}
onChange={(e) => setWithReturn(e.currentTarget.checked)}
onClick={(e) => e.stopPropagation()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
</StepCard>
) : null}
<StepCard>
<StepHeader
icon={<CalendarDays size={22} />}
title="Schedule"
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected."
/>
{cargoQuery === null ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Enter your cargo details first available shipment days depend on
the wagons your cargo needs.
</Alert>
) : (
<Box>
<StepLabel>Shipment day *</StepLabel>
<Box mt={10} w="100%">
<OperationDatePicker
fullWidth
availableDays={availableDays ?? []}
isLoading={daysLoading}
value={scheduledDate}
onChange={setScheduledDate}
/>
</Box>
</Box>
)}
</StepCard>
<StepCard>
<Textarea
label="Additional notes"
placeholder="Any special instructions for this shipment…"
rows={3}
radius="md"
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
/>
</StepCard>
</Stack>
<Box
style={{
position: "sticky",
bottom: 0,
zIndex: 20,
borderTop: "1px solid var(--mantine-color-gray-3)",
backgroundColor: "rgba(255,255,255,0.94)",
backdropFilter: "blur(14px)",
padding: "16px 0",
marginTop: 24,
}}
>
<Box maw={896} mx="auto">
{showErrors && !cargoValid ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted cargo fields before reviewing the price.
</Alert>
) : null}
<Group justify="flex-end">
<Button
variant="default"
radius="md"
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
</Group>
</Box>
</Box>
<Modal
opened={priceOpen}
onClose={() => {
if (!mutations.createBooking.isPending) setPriceOpen(false);
}}
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>
)}
{overweightLines.length > 0 && (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
title="Overweight containers"
>
<Stack gap={6}>
{overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds
limit {line.maxAllowedTons}t (+{line.excessTons}t
overweight)
</Text>
))}
<Text fz="xs" c="#9A5B00" mt={2}>
An overweight surcharge applies (included in the total
below).
</Text>
</Stack>
</Alert>
)}
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
<Stack gap={10}>
{displayTotal.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={500}>
{line.label}
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
{line.amount.toLocaleString()} {displayTotal.currency}
</Text>
</Group>
))}
{displayTotal.lines.length === 0 && (
<Text fz="sm" c="dimmed">
No priced lines check the cargo details.
</Text>
)}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="edr-green"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28}>
{displayTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{displayTotal.currency}
</Text>
</Text>
</Group>
</Paper>
<Group justify="space-between" mt="xs">
<Button
variant="default"
radius="md"
leftSection={<X size={16} />}
onClick={() => setPriceOpen(false)}
disabled={mutations.createBooking.isPending}
>
Reject &amp; edit
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
loading={mutations.createBooking.isPending}
disabled={
validateShipmentMutation.isPending ||
pairingErrors.length > 0 ||
capacityErrors.length > 0
}
onClick={handleSubmit}
>
Confirm &amp; book
</Button>
</Group>
</Stack>
) : null}
</Modal>
</>
) : null}
</PageContainer>
);
}