mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- 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.
1443 lines
50 KiB
TypeScript
1443 lines
50 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,
|
||
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 & 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 & 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>
|
||
);
|
||
}
|