mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add unit of measure selection for cargo types and update related calculations in booking forms
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user