Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
2026-07-04 01:11:23 +00:00

1068 lines
35 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,
Box,
Button,
Center,
Divider,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
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";
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;
}
interface ContainerLineDraft {
containerSize: string;
hazardousQuantity: number | string;
reeferQuantity: number | 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: "" };
}
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],
);
// Soonest future window across all routes, used for the "next window" notice.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
)[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [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,
hazardousQuantity: c.hazardousQuantity ?? "0",
reeferQuantity: c.reeferQuantity ?? "",
units: Array.from({ length: Math.max(1, c.quantity) }, () =>
emptyUnit(),
),
})),
);
} 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,
hazardousQuantity: "0",
reeferQuantity: "0",
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: Number(l.hazardousQuantity || 0),
reeferQuantity: Number(l.reeferQuantity || 0),
})),
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)),
);
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,
...(l.hazardousQuantity !== ""
? { hazardousQuantity: Number(l.hazardousQuantity) }
: {}),
...(l.reeferQuantity !== ""
? { reeferQuantity: Number(l.reeferQuantity) }
: {}),
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
...(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 = () => {
setPriceOpen(true);
const payload = buildPayload();
if (payload) {
validateShipmentMutation.reset();
validateShipmentMutation.mutate(payload);
}
};
const handleSubmit = () => {
if (!contract || !windowOpen) 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
}
navigate(`/dashboard/bookings/${booking.id}/clearance`);
} else {
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} grow 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}
/>
{contract.isHazardous ? (
<NumberInput
label="Hazardous qty"
min={0}
value={line.hazardousQuantity}
onChange={(v) =>
patchLine(lineIdx, { hazardousQuantity: v })
}
radius={10}
styles={fieldStyles}
/>
) : null}
{contract.isReefer ? (
<NumberInput
label="Reefer qty"
min={0}
value={line.reeferQuantity}
onChange={(v) =>
patchLine(lineIdx, { reeferQuantity: v })
}
radius={10}
styles={fieldStyles}
/>
) : null}
</Group>
<StepLabel>Per-container details</StepLabel>
<Stack gap={10} mt={8}>
{line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} grow align="flex-start">
<TextInput
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
containerNumber: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
<TextInput
label={unitIdx === 0 ? "Seal number" : undefined}
placeholder="Optional"
value={unit.sealNumber}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
<NumberInput
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5"
min={0}
decimalScale={2}
value={unit.vgmTons}
onChange={(v) =>
patchUnit(lineIdx, unitIdx, { vgmTons: v })
}
radius={10}
styles={fieldStyles}
/>
</Group>
))}
</Stack>
</Box>
))
)}
</Stack>
</StepCard>
) : (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Enter the amount you are shipping for this booking."
/>
<Stack gap={14}>
<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}
onChange={(v) => patchBulk(idx, { cargoWeightTons: v })}
radius={10}
styles={fieldStyles}
/>
) : (
<NumberInput
label="Item count *"
placeholder="e.g. 500"
min={0}
value={line.itemCount}
onChange={(v) => patchBulk(idx, { itemCount: v })}
radius={10}
styles={fieldStyles}
/>
)}
{contract.isHazardous ? (
<NumberInput
label="Hazardous quantity"
min={0}
value={line.hazardousQuantity}
onChange={(v) => patchBulk(idx, { hazardousQuantity: v })}
radius={10}
styles={fieldStyles}
/>
) : null}
{contract.isReefer ? (
<NumberInput
label="Refrigerated quantity"
min={0}
value={line.reeferQuantity}
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,
}}
>
<Group justify="flex-end" maw={896} mx="auto">
<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>
<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>
);
}