From c6198a4c627fb2b20d8243cb2ed2dbce39f95320 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 07:49:53 +0000 Subject: [PATCH] Implement client-side validation for container and bulk cargo fields in booking form --- .../contracts/GlCreateBookingForm.tsx | 166 +++++++++++++++--- .../src/services/contracts.service.ts | 19 +- 2 files changed, 160 insertions(+), 25 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 547e9ce9f..a744453c0 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -64,6 +64,21 @@ import { /** 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", @@ -372,6 +387,72 @@ export default function GlCreateBookingForm() { prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), ); + // Same client-side validation as the customer portal shipment form: ISO + // container numbers (unique within the shipment) and a positive VGM per unit; + // bulk needs a positive quantity with hazardous/reefer portions bounded by it. + const [showErrors, setShowErrors] = useState(false); + + const unitErrors = useMemo(() => { + if (!isContainer) return []; + const numberCounts = new Map(); + 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(() => { + 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) && @@ -401,7 +482,7 @@ export default function GlCreateBookingForm() { hazardousQuantity: l.units.filter((u) => u.hazardous).length, reeferQuantity: l.units.filter((u) => u.reefer).length, units: l.units.map((u) => ({ - containerNumber: u.containerNumber, + containerNumber: u.containerNumber.trim().toUpperCase(), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), vgmTons: Number(u.vgmTons) || 0, })), @@ -458,6 +539,13 @@ export default function GlCreateBookingForm() { 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) { @@ -467,7 +555,7 @@ export default function GlCreateBookingForm() { }; const handleSubmit = () => { - if (!contract || !windowOpen) return; + 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. @@ -483,8 +571,13 @@ export default function GlCreateBookingForm() { } catch { // Non-fatal } - navigate(`/dashboard/bookings/${booking.id}/clearance`); + } + 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}`); } }, @@ -692,6 +785,11 @@ export default function GlCreateBookingForm() { 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, @@ -720,6 +818,11 @@ export default function GlCreateBookingForm() { min={0} decimalScale={2} value={unit.vgmTons} + error={ + showErrors + ? unitErrors[lineIdx]?.[unitIdx]?.vgmTons + : undefined + } onChange={(v) => patchUnit(lineIdx, unitIdx, { vgmTons: v }) } @@ -808,6 +911,7 @@ export default function GlCreateBookingForm() { min={0} decimalScale={2} value={line.cargoWeightTons} + error={showErrors ? bulkErrors[idx]?.quantity : undefined} onChange={(v) => patchBulk(idx, { cargoWeightTons: v })} radius={10} styles={fieldStyles} @@ -818,6 +922,7 @@ export default function GlCreateBookingForm() { 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} @@ -828,6 +933,7 @@ export default function GlCreateBookingForm() { label="Hazardous quantity" min={0} value={line.hazardousQuantity} + error={showErrors ? bulkErrors[idx]?.hazardous : undefined} onChange={(v) => patchBulk(idx, { hazardousQuantity: v })} radius={10} styles={fieldStyles} @@ -838,6 +944,7 @@ export default function GlCreateBookingForm() { label="Refrigerated quantity" min={0} value={line.reeferQuantity} + error={showErrors ? bulkErrors[idx]?.reefer : undefined} onChange={(v) => patchBulk(idx, { reeferQuantity: v })} radius={10} styles={fieldStyles} @@ -905,26 +1012,39 @@ export default function GlCreateBookingForm() { marginTop: 24, }} > - - - - + + {showErrors && !cargoValid ? ( + } + mb="sm" + > + Fix the highlighted cargo fields before reviewing the price. + + ) : null} + + + + + (C.OPS_CLEARANCE_FINALIZE(id)), // ── Booking under contract (GL ET — Path B) ── - createBookingUnderContract: ( + // The API returns { booking, warnings } (CreateBookingUnderContractResult) — + // unwrap to the booking itself so callers can use its id directly. + createBookingUnderContract: async ( id: string, payload: Freight.CreateBookingUnderContractDto, - ) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload), + ): Promise<{ id: string; reference: string; warnings?: string[] }> => { + const result = await postContract<{ + booking?: { id: string; reference: string }; + id?: string; + reference?: string; + warnings?: string[]; + }>(C.BOOKINGS(id), payload); + const booking = result.booking ?? result; + return { + id: booking.id ?? "", + reference: booking.reference ?? "", + warnings: result.warnings, + }; + }, /** * Pre-create validation + authoritative price preview: the same