feat: reorder cargo and route steps in booking form; update related logic and UI components

This commit is contained in:
Marshal
2026-06-23 22:15:29 +00:00
parent 6485cbdd71
commit 733bfd1e5e
5 changed files with 170 additions and 297 deletions

View File

@@ -821,7 +821,6 @@ export default function EditBookingPage() {
<Box>
<Step5CargoDetails
form={form}
direction={direction!}
referenceData={referenceData}
isLoading={!referenceData}
/>

View File

@@ -357,16 +357,14 @@ export default function NewBookingPage() {
// Bulk amount lives in cargoTotalWeightVgm — tons (estimated) or a whole
// item count, depending on the commodity's unit_of_measure. Item counts are
// rounded since fractional items are meaningless. Container totals are the
// summed VGM of all container lines.
// rounded since fractional items are meaningless. Containers carry NO weight
// at the wizard — VGM is captured later in operations — so container bookings
// send 0.
const isPerItem =
bulkChild?.unit_of_measure === Freight.CargoUnitOfMeasure.PerItem;
const totalWeight =
data.cargoType === "container"
? data.containers.reduce(
(acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0),
0,
)
? 0
: isPerItem
? Math.round(Number(data.cargoWeight || 0))
: Number(data.cargoWeight || 0);
@@ -420,7 +418,8 @@ export default function NewBookingPage() {
? data.containers.map((c) => ({
containerTypeId: findContainerTypeId(c.containerType),
quantity: Number(c.qty || 1),
vgmPerUnitTons: Number(c.vgm || 0),
// Weight (VGM) is not collected at the wizard — captured in operations.
vgmPerUnitTons: 0,
}))
: [],
...(data.previousContractRef
@@ -599,16 +598,15 @@ export default function NewBookingPage() {
<Step2ServiceType referenceData={referenceData} form={form} />
)}
{step === 3 && (
<Step4Route
<Step5CargoDetails
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step5CargoDetails
<Step4Route
form={form}
direction={direction!}
referenceData={referenceData}
isLoading={refDataLoading}
/>

View File

@@ -6,8 +6,8 @@ export const STEPS = [
{ id: 0, label: "Operation Type", short: "Operation" },
{ id: 1, label: "Contract Type", short: "Contract" },
{ id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "Route", short: "Route" },
{ id: 4, label: "Cargo Details", short: "Cargo" },
{ id: 3, label: "Cargo Details", short: "Cargo" },
{ id: 4, label: "Route", short: "Route" },
{ id: 5, label: "Shipment Date", short: "Schedule" },
{ id: 6, label: "Documents", short: "Documents" },
{ id: 7, label: "Review & Submit", short: "Submit" },
@@ -158,11 +158,9 @@ export const bookingFormSchema = z
.refine((q) => q.length !== 0, "Quantity is required.")
.refine((q) => !isNaN(+q), "Enter a valid Number")
.refine((qty) => Number(qty) >= 1, "Must be greater than 0"),
vgm: z
.string()
.refine((vgm) => vgm.length !== 0, "VGM is required.")
.refine((vgm) => !isNaN(+vgm), "Enter a valid Number")
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
// Weight (VGM) is NOT collected at the wizard — it is captured later in
// operations. Kept optional so existing payload code stays valid.
vgm: z.string().default("0"),
}),
),
// Consolidation is system-managed, not a customer choice: the backend
@@ -202,12 +200,10 @@ export const bookingFormSchema = z
.refine(
(data) => {
if (data.cargoType !== "bulk") return true;
const cargoWeight = Number(data.cargoWeight);
return (
!!data.cargoWeight && !Number.isNaN(cargoWeight) && cargoWeight > 0
);
const quantity = Number(data.cargoWeight);
return !!data.cargoWeight && !Number.isNaN(quantity) && quantity > 0;
},
{ message: "Enter a cargo weight greater than 0.", path: ["cargoWeight"] },
{ message: "Enter a quantity greater than 0.", path: ["cargoWeight"] },
)
.refine(
(data) => !(data.cargoType === "container" && data.containers.length === 0),
@@ -240,14 +236,6 @@ export const bookingFormSchema = z
message: "Enter at least 1 container.",
});
}
if (!c.vgm || +c.vgm <= 0) {
ctx.addIssue({
code: "custom",
path: ["containers", i, "vgm"],
message: "Enter VGM greater than 0.",
});
}
});
}
});
@@ -281,7 +269,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
cargoFreeText: "",
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "0" }],
documents: {},
notes: "",
};
@@ -297,7 +285,8 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"equipmentReturn",
"customsClearingEnabled",
],
3: [
3: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
4: [
"originYard",
"destinationYard",
"extraRoutes",
@@ -305,7 +294,6 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"isRefrigerated",
"shippingLine",
],
4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],

View File

@@ -1,17 +1,11 @@
import { useEffect, useMemo } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { Package, Plus, Trash2, Weight } from "lucide-react";
import {
ActionIcon,
Button,
Skeleton,
Text,
TextInput,
} from "@mantine/core";
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
calcWagons,
calcWagons,
type BookingFormValues,
} from "./schema";
import {
@@ -33,12 +27,10 @@ type BookingForm = UseFormReturn<
export function Step5CargoDetails({
form,
direction,
referenceData,
isLoading,
}: {
form: BookingForm;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
isLoading?: boolean;
}) {
@@ -66,21 +58,6 @@ export function Step5CargoDetails({
}
}, [parentId]);
// For containerised cargo, the total weight is derived from the containers
// (Σ qty × vgm) rather than typed by hand — keep cargoWeight in sync.
useEffect(() => {
if (cargoType !== "container") return;
const total = (containers ?? []).reduce(
(sum, c) => sum + (Number(c?.qty) || 0) * (Number(c?.vgm) || 0),
0,
);
form.setValue("cargoWeight", total ? String(total) : "", {
shouldValidate: true,
});
// form is stable; re-run when the containers or cargo type change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containers, cargoType]);
const selectedCommodity = useMemo(() => {
if (!referenceData?.cargo_type || !parentId || !childId) return null;
const group = referenceData.cargo_type.find((g) => g.id === parentId);
@@ -88,8 +65,8 @@ export function Step5CargoDetails({
}, [referenceData, parentId, childId]);
// Unit of measure for bulk/break-bulk cargo: PER_ITEM → ask for a total item
// count; otherwise ask for estimated tons. Drives the amount field's label,
// icon, and step so customers enter the right unit.
// count; otherwise ask for an estimated tonnage. Drives the quantity field's
// label, icon, and step so customers enter the right unit.
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const freightTypeGroups = useMemo(() => {
@@ -119,29 +96,13 @@ export function Step5CargoDetails({
);
}, [referenceData, parentId]);
function getOverweightAlert(
type: "20ft" | "40ft",
vgm: number,
): string | null {
if (type === "20ft" && vgm > 0) {
const limit = direction === "EXPORT" ? 25 : 20;
if (vgm > limit) {
return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`;
}
}
if (type === "40ft" && vgm > 32.5) {
return `VGM ${vgm}t exceeds the 32.5t global limit for a 40ft container. An Overweight Surcharge will apply.`;
}
return null;
}
if (isLoading) {
return (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
description="Choose your cargo type and configuration."
/>
<div className="space-y-4">
<Skeleton height={14} w={96} radius="sm" />
@@ -161,7 +122,7 @@ export function Step5CargoDetails({
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
description="Choose your cargo type and configuration. Container weight is captured later in operations."
/>
{/* Cargo Type */}
@@ -204,36 +165,8 @@ export function Step5CargoDetails({
/>
</div>
{/* Containerised cargo: total weight is auto-summed from the containers
below, so we show it here up-front as a read-only running total. */}
{cargoType === "container" && (
<div className="space-y-3">
<Controller
name="cargoWeight"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
id="cargoWeight"
type="number"
label="Total Cargo Weight (Tons) *"
placeholder="0.00"
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
readOnly
description="Auto-calculated from the containers below."
radius={10}
styles={fieldStyles}
min={0}
step={0.01}
/>
)}
/>
</div>
)}
{/* Bulk freight type — pick the commodity FIRST so we know whether the
cargo is measured in tons or items before asking for the amount. */}
cargo is measured in tons or items before asking for the quantity. */}
{cargoType === "bulk" && (
<div className="space-y-3">
{freightTypeOptions.length > 0 ? (
@@ -288,9 +221,9 @@ export function Step5CargoDetails({
/>
)}
{/* Amount — only once a commodity is chosen, so the unit (tons vs
items) is known. PER_TON asks for estimated tons to ship;
PER_ITEM asks for the total item count to import/export. */}
{/* Quantity — only once a commodity is chosen, so the unit (tons vs
items) is known. PER_TON asks for estimated tons; PER_ITEM asks
for the total item count. */}
{selectedCommodity && (
<Controller
name="cargoWeight"
@@ -300,11 +233,7 @@ export function Step5CargoDetails({
{...field}
id="cargoWeight"
type="number"
label={
isPerItem
? "Total Number of Items *"
: "Estimated Total Tons *"
}
label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"}
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
leftSection={
isPerItem ? (
@@ -317,7 +246,7 @@ export function Step5CargoDetails({
description={
isPerItem
? "Total count of items you plan to import or export."
: "Your best estimate of the total weight to ship, in tons."
: "Estimated total tonnage to ship."
}
radius={10}
styles={fieldStyles}
@@ -330,7 +259,7 @@ export function Step5CargoDetails({
</div>
)}
{/* Container list */}
{/* Container list — type + quantity only; no weight is collected here. */}
{cargoType === "container" && (
<>
<div className="space-y-4">
@@ -343,190 +272,148 @@ export function Step5CargoDetails({
radius="md"
leftSection={<Plus size={14} />}
onClick={() =>
append({ type: "20ft", containerType: "", qty: "1", vgm: "" })
append({
type: "20ft",
containerType: "",
qty: "1",
vgm: "0",
})
}
>
Add Container
</Button>
</div>
{fields.map((field, index) => {
const containerType = containers[index]?.type;
const vgm = containers[index]?.vgm ?? 0;
const alert = getOverweightAlert(containerType, +vgm);
return (
<div
key={field.id}
className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
>
<div className="flex items-center justify-between">
<Text
size="xs"
fw={600}
c="dimmed"
tt="uppercase"
className="tracking-wide"
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
>
<div className="flex items-center justify-between">
<Text
size="xs"
fw={600}
c="dimmed"
tt="uppercase"
className="tracking-wide"
>
Container {index + 1}
</Text>
{fields.length > 1 && (
<ActionIcon
color="red"
variant="subtle"
size="sm"
onClick={() => remove(index)}
aria-label="Remove container"
>
Container {index + 1}
</Text>
{fields.length > 1 && (
<ActionIcon
color="red"
variant="subtle"
size="sm"
onClick={() => remove(index)}
aria-label="Remove container"
>
<Trash2 size={15} />
</ActionIcon>
)}
</div>
<Trash2 size={15} />
</ActionIcon>
)}
</div>
{/* Container size */}
{/* Container size */}
<Controller
name={`containers.${index}.type`}
control={form.control}
render={({ field: typeField, fieldState }) => (
<div>
<div className="grid gap-2 sm:grid-cols-2">
{[
{ val: "20ft" as const, label: "20ft Container (TEU)" },
{ val: "40ft" as const, label: "40ft Container (FEU)" },
].map((ct) => (
<OptionCard
key={ct.val}
selected={typeField.value === ct.val}
onClick={() => typeField.onChange(ct.val)}
>
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-emerald-600" />
<p className="font-semibold">{ct.label}</p>
</div>
</OptionCard>
))}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
{/* Quantity + Container Type */}
<div className="grid gap-3 sm:grid-cols-2">
<Controller
name={`containers.${index}.type`}
name={`containers.${index}.qty`}
control={form.control}
render={({ field: typeField, fieldState }) => (
render={({ field: qtyField, fieldState }) => (
<div>
<div className="grid gap-2 sm:grid-cols-2">
{[
{
val: "20ft" as const,
label: "20ft Container (TEU)",
limit:
direction === "EXPORT"
? "Max 25t per container"
: "Max 20t per container",
},
{
val: "40ft" as const,
label: "40ft Container (FEU)",
limit: "Max 32.5t per container",
},
].map((ct) => (
<OptionCard
key={ct.val}
selected={typeField.value === ct.val}
onClick={() => typeField.onChange(ct.val)}
>
<div className="mb-1 flex items-center gap-2">
<Package className="h-4 w-4 text-emerald-600" />
<p className="font-semibold">{ct.label}</p>
</div>
<p className="text-xs text-gray-500">
{ct.limit}
</p>
</OptionCard>
))}
<Text size="sm" fw={500} mb={4}>
Quantity *
</Text>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
qtyField.onChange(
Math.max(
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
</button>
<input
value={qtyField.value ?? 1}
onChange={(e) => qtyField.onChange(e.target.value)}
onBlur={qtyField.onBlur}
type="number"
min={1}
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() =>
qtyField.onChange(
(Number(qtyField.value ?? 1) + 1).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
+
</button>
</div>
<OptionFieldError error={fieldState.error} />
{fieldState.error?.message && (
<Text size="xs" c="red" mt={4}>
{fieldState.error.message}
</Text>
)}
</div>
)}
/>
{/* Qty + VGM + Type */}
<div className="grid gap-3 sm:grid-cols-3">
<Controller
name={`containers.${index}.qty`}
control={form.control}
render={({ field: qtyField, fieldState }) => (
<div>
<Text size="sm" fw={500} mb={4}>
Quantity *
</Text>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
qtyField.onChange(
Math.max(
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
</button>
<input
value={qtyField.value ?? 1}
onChange={(e) =>
qtyField.onChange(e.target.value)
}
onBlur={qtyField.onBlur}
type="number"
min={1}
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() =>
qtyField.onChange(
(Number(qtyField.value ?? 1) + 1).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
+
</button>
</div>
{fieldState.error?.message && (
<Text size="xs" c="red" mt={4}>
{fieldState.error.message}
</Text>
)}
</div>
)}
/>
<Controller
name={`containers.${index}.vgm`}
control={form.control}
render={({ field: vgmField, fieldState }) => (
<TextInput
value={vgmField.value ?? 0}
onChange={(e) => vgmField.onChange(e.target.value)}
onBlur={vgmField.onBlur}
type="number"
label="Tons *"
placeholder="e.g. 18.5"
error={fieldState.error?.message}
radius="md"
min={0}
step={0.1}
/>
)}
/>
<Controller
name={`containers.${index}.containerType`}
control={form.control}
render={({ field: ctField, fieldState }) => (
<SelectField
field={ctField}
error={fieldState.error}
label="Container Type *"
placeholder="Select type..."
data={
containerTypeOptionsBySize.get(
containers[index]?.type ?? "20ft",
) ?? []
}
/>
)}
/>
</div>
{alert && (
<AlertBox tone="warning">
<strong>Overweight Alert:</strong> {alert}
</AlertBox>
)}
<Controller
name={`containers.${index}.containerType`}
control={form.control}
render={({ field: ctField, fieldState }) => (
<SelectField
field={ctField}
error={fieldState.error}
label="Container Type *"
placeholder="Select type..."
data={
containerTypeOptionsBySize.get(
containers[index]?.type ?? "20ft",
) ?? []
}
/>
)}
/>
</div>
);
})}
</div>
))}
</div>
{(() => {
@@ -536,10 +423,10 @@ export function Step5CargoDetails({
<AlertBox tone="warning">
<p className="font-semibold">Unpaired 20ft Container</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon
will depart once a co-loader is found to fill the remaining
slot, which <strong>may delay departure</strong> beyond the
standard lead time.
One 20ft container occupies only half a wagon. The wagon will
depart once a co-loader is found to fill the remaining slot,
which <strong>may delay departure</strong> beyond the standard
lead time.
</p>
</AlertBox>
);

View File

@@ -33,8 +33,8 @@ import { StepHeader } from "./shared";
export const REVIEW_STEP_TARGETS = {
contract: 1,
service: 2,
route: 3,
cargo: 4,
cargo: 3,
route: 4,
schedule: 5,
documents: 6,
} as const;
@@ -351,17 +351,19 @@ export function Step8Review({
onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
>
<DetailRow label="Freight type" value={cargoValue} />
<DetailRow
label={totalQuantityRow.label}
value={totalQuantityRow.value}
/>
{/* Containers carry no weight at the wizard — only bulk shows a quantity row. */}
{values.cargoType === "bulk" && (
<DetailRow
label={totalQuantityRow.label}
value={totalQuantityRow.value}
/>
)}
{values.cargoType === "container" && values.containers.length > 0 && (
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM (t)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -371,7 +373,6 @@ export function Step8Review({
<Table.Tr key={i}>
<Table.Td>{c.containerType || c.type}</Table.Td>
<Table.Td>{c.qty}</Table.Td>
<Table.Td>{c.vgm}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>