feat: add unit of measure selection for cargo types and update related calculations in booking forms

This commit is contained in:
Marshal
2026-06-23 21:00:51 +00:00
parent 080a08229d
commit 29f6928059
4 changed files with 136 additions and 49 deletions

View File

@@ -35,6 +35,7 @@ import { canAccessRuleEngineResource } from "@/lib/permissions";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import {
getRuleEngineResource,
RULE_ENGINE_SELECT_NONE,
type FormFieldDef,
} from "@/pages/ruleEngine/config/resources";
import {
@@ -52,6 +53,8 @@ interface CargoNode extends RuleEngineRecord {
parentGroupId?: string | null;
showFreeTextBox?: boolean;
requiresDirectorApproval?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
isActive?: boolean;
displayOrder?: number;
}
@@ -62,6 +65,21 @@ const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
/** Create/edit form fields. Parent is set from the current page, never picked. */
const FORM_FIELDS: FormFieldDef[] = [
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
{
// How this cargo is measured. Optional — leave "None" for grouping
// categories; set it on the actual commodities so bookings ask for the
// right amount (estimated tons vs. total item count).
name: "unitOfMeasure",
label: "Unit of measure",
type: "select",
optional: true,
placeholder: "Select unit (optional)",
options: [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
{ label: "Per ton (bulk)", value: "PER_TON" },
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
],
},
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
@@ -469,6 +487,13 @@ function CargoRow({
</Badge>
</Tooltip>
) : null}
{node.unitOfMeasure ? (
<Tooltip label="How bookings measure this cargo" withArrow>
<Badge size="xs" variant="light" color="teal" radius="sm">
{node.unitOfMeasure === "PER_ITEM" ? "Per item" : "Per ton"}
</Badge>
</Tooltip>
) : null}
{inactive ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
Inactive

View File

@@ -337,14 +337,6 @@ export default function NewBookingPage() {
throw new Error("Validation failed");
}
const totalWeight =
data.cargoType === "container"
? data.containers.reduce(
(acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0),
0,
)
: Number(data.cargoWeight || 0);
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
@@ -363,6 +355,22 @@ export default function NewBookingPage() {
.flatMap((g) => g.children ?? [])
.find((c) => c.id === childId);
// 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.
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,
)
: isPerItem
? Math.round(Number(data.cargoWeight || 0))
: Number(data.cargoWeight || 0);
const cargoTypeId = data.cargoType === "bulk" ? childId : undefined;
const cargoFreeText = bulkChild?.show_free_text_box

View File

@@ -87,10 +87,10 @@ export function Step5CargoDetails({
return group?.children?.find((c) => c.id === childId) ?? null;
}, [referenceData, parentId, childId]);
// Unit of measure for bulk/break-bulk cargo: PER_ITEM → "Items", else "Tons".
// Drives the weight/quantity label so customers enter the right unit.
// 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.
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const bulkUnitLabel = isPerItem ? "Items" : "Tons";
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
@@ -204,41 +204,36 @@ export function Step5CargoDetails({
/>
</div>
{/* Weight */}
<div className="space-y-3">
<Controller
name="cargoWeight"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
id="cargoWeight"
type="number"
label={
cargoType === "bulk"
? `Total Cargo Quantity (${bulkUnitLabel}) *`
: "Total Cargo Weight (Tons) *"
}
placeholder={isPerItem ? "0" : "0.00"}
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
// Container total is auto-summed from the containers below.
readOnly={cargoType === "container"}
description={
cargoType === "container"
? "Auto-calculated from the containers below."
: undefined
}
radius={10}
styles={fieldStyles}
min={0}
step={isPerItem ? 1 : 0.01}
/>
)}
/>
</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 */}
{/* Bulk freight type — pick the commodity FIRST so we know whether the
cargo is measured in tons or items before asking for the amount. */}
{cargoType === "bulk" && (
<div className="space-y-3">
{freightTypeOptions.length > 0 ? (
@@ -269,8 +264,8 @@ export function Step5CargoDetails({
<SelectField
field={field}
error={fieldState.error}
label=""
placeholder="Select type *"
label="Commodity *"
placeholder="Select type..."
data={commodityOptions}
/>
)}
@@ -292,6 +287,46 @@ 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. */}
{selectedCommodity && (
<Controller
name="cargoWeight"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
id="cargoWeight"
type="number"
label={
isPerItem
? "Total Number of Items *"
: "Estimated Total Tons *"
}
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
leftSection={
isPerItem ? (
<Package className="h-4 w-4" />
) : (
<Weight className="h-4 w-4" />
)
}
error={fieldState.error?.message}
description={
isPerItem
? "Total count of items you plan to import or export."
: "Your best estimate of the total weight to ship, in tons."
}
radius={10}
styles={fieldStyles}
min={0}
step={isPerItem ? 1 : 0.01}
/>
)}
/>
)}
</div>
)}

View File

@@ -173,6 +173,13 @@ export function Step8Review({
// Documents are reused from onboarding (read-only) and attached on submit.
const onboardingDocsCount = onboardingDocs.length;
const selectedCommodity = (() => {
if (values.cargoType !== "bulk" || !referenceData) return null;
const path = values.cargoTypePath ?? [];
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
return group?.children?.find((c) => c.id === path[1]) ?? null;
})();
const cargoValue = (() => {
if (values.cargoType === "container") return "Container freight";
if (!referenceData) return "";
@@ -183,6 +190,18 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name;
})();
// Bulk PER_ITEM cargo is a whole item count, not tons — label it accordingly.
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const totalQuantityRow = isPerItem
? {
label: "Total quantity",
value: totalVgm > 0 ? `${Math.round(totalVgm)} items` : "—",
}
: {
label: "Total VGM",
value: totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—",
};
const originYardName =
referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
values.originYard;
@@ -333,8 +352,8 @@ export function Step8Review({
>
<DetailRow label="Freight type" value={cargoValue} />
<DetailRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
label={totalQuantityRow.label}
value={totalQuantityRow.value}
/>
{values.cargoType === "container" && values.containers.length > 0 && (
<Table mt="sm" withTableBorder withColumnBorders fz="sm">