diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index 43dcac46a..890796758 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -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({ ) : null} + {node.unitOfMeasure ? ( + + + {node.unitOfMeasure === "PER_ITEM" ? "Per item" : "Per ton"} + + + ) : null} {inactive ? ( Inactive diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 2e2cc3e85..5a4e67d0d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -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 diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index 7a21ef3d0..b31a83174 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -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({ /> - {/* Weight */} -
- ( - } - 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} - /> - )} - /> -
+ {/* 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" && ( +
+ ( + } + error={fieldState.error?.message} + readOnly + description="Auto-calculated from the containers below." + radius={10} + styles={fieldStyles} + min={0} + step={0.01} + /> + )} + /> +
+ )} - {/* 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" && (
{freightTypeOptions.length > 0 ? ( @@ -269,8 +264,8 @@ export function Step5CargoDetails({ )} @@ -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 && ( + ( + + ) : ( + + ) + } + 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} + /> + )} + /> + )}
)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 9cae7d510..5acbc0213 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -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({ > 0 ? `${totalVgm.toFixed(1)} tons` : "—"} + label={totalQuantityRow.label} + value={totalQuantityRow.value} /> {values.cargoType === "container" && values.containers.length > 0 && (