Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx

1232 lines
42 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,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Switch,
Text,
Textarea,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
FileText,
MapPin,
Package,
Receipt,
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 {
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 [prefilled, setPrefilled] = useState(false);
const [priceOpen, setPriceOpen] = useState(false);
const seededRef = useRef(false);
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);
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() } : {}),
};
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}>
<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>
)}
<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>
);
}