mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Implement client-side validation for container and bulk cargo fields in booking form
This commit is contained in:
@@ -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<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) &&
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<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 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
|
||||
|
||||
@@ -482,10 +482,25 @@ export const contractsService = {
|
||||
postContract<Freight.IContract>(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
|
||||
|
||||
Reference in New Issue
Block a user