mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +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. */
|
/** 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 & 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 & book
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user