mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
1068 lines
35 KiB
TypeScript
1068 lines
35 KiB
TypeScript
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 & 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 & 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 & book
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
) : null}
|
||
</Modal>
|
||
</>
|
||
) : null}
|
||
</PageContainer>
|
||
);
|
||
}
|