Implement client-side validation for container and bulk cargo fields in booking form

This commit is contained in:
Marshal
2026-07-09 07:49:53 +00:00
parent 61ec67cc2e
commit c6198a4c62
2 changed files with 160 additions and 25 deletions

View File

@@ -64,6 +64,21 @@ import {
/** All booking-window times are communicated in East Africa Time. */ /** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa"; 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 { function fmtWindowOpensAt(iso: string): string {
const date = new Date(iso).toLocaleDateString("en-GB", { const date = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short", weekday: "short",
@@ -372,6 +387,72 @@ export default function GlCreateBookingForm() {
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
); );
// Same client-side validation as the customer portal shipment form: ISO
// container numbers (unique within the shipment) and a positive VGM per unit;
// bulk needs a positive quantity with hazardous/reefer portions bounded by it.
const [showErrors, setShowErrors] = useState(false);
const unitErrors = useMemo<UnitErrors[][]>(() => {
if (!isContainer) return [];
const numberCounts = new Map<string, number>();
containerLines.forEach((line) =>
line.units.forEach((u) => {
const key = u.containerNumber.trim().toUpperCase();
if (!key) return;
numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1);
}),
);
return containerLines.map((line) =>
line.units.map((u) => {
const errs: UnitErrors = {};
const key = u.containerNumber.trim().toUpperCase();
if (!key) {
errs.containerNumber = "Container number is required.";
} else if (!ISO_CONTAINER_NUMBER_REGEX.test(key)) {
errs.containerNumber =
"Enter a valid ISO container number (e.g. ABCD1234567).";
} else if ((numberCounts.get(key) ?? 0) > 1) {
errs.containerNumber = "Duplicate container number in this shipment.";
}
const vgm = Number(u.vgmTons);
if (String(u.vgmTons).trim() === "" || Number.isNaN(vgm) || vgm <= 0) {
errs.vgmTons = "Enter a valid VGM.";
}
return errs;
}),
);
}, [isContainer, containerLines]);
const bulkErrors = useMemo<BulkErrors[]>(() => {
if (isContainer) return [];
return bulkLines.map((line) => {
const errs: BulkErrors = {};
const qty = Number(line.cargoWeightTons || line.itemCount || 0);
if (Number.isNaN(qty) || qty <= 0) {
errs.quantity = "Enter a quantity greater than 0.";
}
const h = Number(line.hazardousQuantity || 0);
if (Number.isNaN(h) || h < 0) {
errs.hazardous = "Enter a valid hazardous quantity.";
} else if (qty > 0 && h > qty) {
errs.hazardous = `Can't exceed the cargo quantity (${qty}).`;
}
const r = Number(line.reeferQuantity || 0);
if (Number.isNaN(r) || r < 0) {
errs.reefer = "Enter a valid refrigerated quantity.";
} else if (qty > 0 && r > qty) {
errs.reefer = `Can't exceed the cargo quantity (${qty}).`;
}
return errs;
});
}, [isContainer, bulkLines]);
const cargoValid = isContainer
? unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
)
: bulkErrors.every((e) => !e.quantity && !e.hazardous && !e.reefer);
const canSubmit = const canSubmit =
windowOpen && windowOpen &&
Boolean(scheduledDate) && Boolean(scheduledDate) &&
@@ -401,7 +482,7 @@ export default function GlCreateBookingForm() {
hazardousQuantity: l.units.filter((u) => u.hazardous).length, hazardousQuantity: l.units.filter((u) => u.hazardous).length,
reeferQuantity: l.units.filter((u) => u.reefer).length, reeferQuantity: l.units.filter((u) => u.reefer).length,
units: l.units.map((u) => ({ units: l.units.map((u) => ({
containerNumber: u.containerNumber, containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0, vgmTons: Number(u.vgmTons) || 0,
})), })),
@@ -458,6 +539,13 @@ export default function GlCreateBookingForm() {
const overweightLines = validation?.overweightLines ?? []; const overweightLines = validation?.overweightLines ?? [];
const openPriceModal = () => { 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); setPriceOpen(true);
const payload = buildPayload(); const payload = buildPayload();
if (payload) { if (payload) {
@@ -467,7 +555,7 @@ export default function GlCreateBookingForm() {
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (!contract || !windowOpen) return; if (!contract || !windowOpen || !cargoValid) return;
// Never book past unresolved 20ft pairing hard-blocks. // Never book past unresolved 20ft pairing hard-blocks.
if (pairingErrors.length > 0) return; if (pairingErrors.length > 0) return;
// A line above the container type's max capacity can never book. // A line above the container type's max capacity can never book.
@@ -483,8 +571,13 @@ export default function GlCreateBookingForm() {
} catch { } catch {
// Non-fatal // 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 { } else {
// ONE_TIME customs keeps its clearance on the contract.
navigate(`/dashboard/contracts/clearance/${contract.id}`); navigate(`/dashboard/contracts/clearance/${contract.id}`);
} }
}, },
@@ -692,6 +785,11 @@ export default function GlCreateBookingForm() {
label={unitIdx === 0 ? "Container number *" : undefined} label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567" placeholder="e.g. MSCU1234567"
value={unit.containerNumber} value={unit.containerNumber}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.containerNumber
: undefined
}
onChange={(e) => onChange={(e) =>
patchUnit(lineIdx, unitIdx, { patchUnit(lineIdx, unitIdx, {
containerNumber: e.currentTarget.value, containerNumber: e.currentTarget.value,
@@ -720,6 +818,11 @@ export default function GlCreateBookingForm() {
min={0} min={0}
decimalScale={2} decimalScale={2}
value={unit.vgmTons} value={unit.vgmTons}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.vgmTons
: undefined
}
onChange={(v) => onChange={(v) =>
patchUnit(lineIdx, unitIdx, { vgmTons: v }) patchUnit(lineIdx, unitIdx, { vgmTons: v })
} }
@@ -808,6 +911,7 @@ export default function GlCreateBookingForm() {
min={0} min={0}
decimalScale={2} decimalScale={2}
value={line.cargoWeightTons} value={line.cargoWeightTons}
error={showErrors ? bulkErrors[idx]?.quantity : undefined}
onChange={(v) => patchBulk(idx, { cargoWeightTons: v })} onChange={(v) => patchBulk(idx, { cargoWeightTons: v })}
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
@@ -818,6 +922,7 @@ export default function GlCreateBookingForm() {
placeholder="e.g. 500" placeholder="e.g. 500"
min={0} min={0}
value={line.itemCount} value={line.itemCount}
error={showErrors ? bulkErrors[idx]?.quantity : undefined}
onChange={(v) => patchBulk(idx, { itemCount: v })} onChange={(v) => patchBulk(idx, { itemCount: v })}
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
@@ -828,6 +933,7 @@ export default function GlCreateBookingForm() {
label="Hazardous quantity" label="Hazardous quantity"
min={0} min={0}
value={line.hazardousQuantity} value={line.hazardousQuantity}
error={showErrors ? bulkErrors[idx]?.hazardous : undefined}
onChange={(v) => patchBulk(idx, { hazardousQuantity: v })} onChange={(v) => patchBulk(idx, { hazardousQuantity: v })}
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
@@ -838,6 +944,7 @@ export default function GlCreateBookingForm() {
label="Refrigerated quantity" label="Refrigerated quantity"
min={0} min={0}
value={line.reeferQuantity} value={line.reeferQuantity}
error={showErrors ? bulkErrors[idx]?.reefer : undefined}
onChange={(v) => patchBulk(idx, { reeferQuantity: v })} onChange={(v) => patchBulk(idx, { reeferQuantity: v })}
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
@@ -905,26 +1012,39 @@ export default function GlCreateBookingForm() {
marginTop: 24, marginTop: 24,
}} }}
> >
<Group justify="flex-end" maw={896} mx="auto"> <Box maw={896} mx="auto">
<Button {showErrors && !cargoValid ? (
variant="default" <Alert
radius="md" color="red"
onClick={() => variant="light"
navigate(`/dashboard/contracts/clearance/${contract.id}`) radius="md"
} icon={<AlertCircle size={16} />}
> mb="sm"
Cancel >
</Button> Fix the highlighted cargo fields before reviewing the price.
<Button </Alert>
color="edr-green" ) : null}
radius="md" <Group justify="flex-end">
leftSection={<Receipt size={16} />} <Button
disabled={!canSubmit} variant="default"
onClick={openPriceModal} radius="md"
> onClick={() =>
Review price &amp; book navigate(`/dashboard/contracts/clearance/${contract.id}`)
</Button> }
</Group> >
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
</Group>
</Box>
</Box> </Box>
<Modal <Modal

View File

@@ -482,10 +482,25 @@ export const contractsService = {
postContract<Freight.IContract>(C.OPS_CLEARANCE_FINALIZE(id)), postContract<Freight.IContract>(C.OPS_CLEARANCE_FINALIZE(id)),
// ── Booking under contract (GL ET — Path B) ── // ── 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, id: string,
payload: Freight.CreateBookingUnderContractDto, 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 * Pre-create validation + authoritative price preview: the same