feat(bookings): Implement unified hierarchical bulk cargo type selection via cargoTypePath

This commit is contained in:
ghost2023
2026-06-13 12:37:11 +03:00
parent 3e2e07bb5f
commit d1347892db
3 changed files with 81 additions and 74 deletions

View File

@@ -121,12 +121,15 @@ export default function NewBookingPage() {
return "";
};
const cargoTypePath = data.cargoTypePath ?? [];
const childId = cargoTypePath[1];
const bulkChild = cargoTree
.flatMap((g) => g.children ?? [])
.find((c) => c.id === data.bulkCommoditytype);
.find((c) => c.id === childId);
const cargoTypeId =
data.cargoType === "bulk" ? data.bulkCommoditytype : undefined;
data.cargoType === "bulk" ? childId : undefined;
const cargoFreeText = bulkChild?.show_free_text_box
? data.cargoFreeText

View File

@@ -104,8 +104,7 @@ export const bookingFormSchema = z
trainScheduleId: z.string().min(1, "Select a shipment date."),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
freightType: z.string(), // parent group
bulkCommoditytype: z.string(),
cargoTypePath: z.array(z.string()).default([]),
cargoFreeText: z.string(),
isHazardous: z.boolean(),
isRefrigerated: z.boolean(),
@@ -158,19 +157,6 @@ export const bookingFormSchema = z
path: ["destinationYard"],
},
)
.refine((data) => !(data.cargoType === "bulk" && !data.freightType), {
message: "Select a freight type.",
path: ["freightType"],
})
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType &&
!data.bulkCommoditytype
),
{ message: "Select a commodity.", path: ["bulkCommoditytype"] },
)
.refine(
(data) => {
if (data.cargoType !== "bulk") return true;
@@ -190,6 +176,21 @@ export const bookingFormSchema = z
path: ["termsAccepted"],
})
.superRefine((data, ctx) => {
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({
code: "custom",
path: ["cargoTypePath"],
message: "Select a freight type.",
});
} else if (!data.cargoTypePath[1]) {
ctx.addIssue({
code: "custom",
path: ["cargoTypePath"],
message: "Select a commodity.",
});
}
}
if (data.cargoType === "container") {
data.containers.forEach((c, i) => {
if (!c.qty || +c.qty < 1) {
@@ -233,7 +234,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
scheduledDate: "",
trainScheduleId: "",
cargoWeight: "",
bulkCommoditytype: "",
cargoTypePath: [],
cargoFreeText: "",
isHazardous: false,
isRefrigerated: false,
@@ -263,8 +264,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
4: [
"cargoType",
"cargoWeight",
"freightType",
"bulkCommoditytype",
"cargoTypePath",
"containers",
"consolidationEnabled",
],

View File

@@ -1,11 +1,11 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import { Package, Plus, Trash2, Weight } from "lucide-react";
import {
ActionIcon,
Button,
Skeleton,
Stack,
InputLabel,
Text,
TextInput,
} from "@mantine/core";
@@ -42,8 +42,9 @@ export function Step5CargoDetails({
isLoading?: boolean;
}) {
const cargoType = form.watch("cargoType");
const freightType = form.watch("freightType");
const bulkCommoditytype = form.watch("bulkCommoditytype");
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const parentId = cargoTypePath[0];
const childId = cargoTypePath[1];
const containers = form.watch("containers");
const { fields, append, remove } = useFieldArray({
@@ -58,15 +59,17 @@ export function Step5CargoDetails({
);
}, [referenceData]);
useEffect(() => {
if (parentId) {
form.setValue("cargoTypePath", [parentId, ""], { shouldDirty: true });
}
}, [parentId]);
const selectedCommodity = useMemo(() => {
if (!referenceData?.cargo_type || !freightType || !bulkCommoditytype) return null;
const group = referenceData.cargo_type.find(
(g) => g.code.toLowerCase() === freightType,
);
return group?.children?.find(
(c) => c.name === bulkCommoditytype,
) ?? null;
}, [referenceData, freightType, bulkCommoditytype]);
if (!referenceData?.cargo_type || !parentId || !childId) return null;
const group = referenceData.cargo_type.find((g) => g.id === parentId);
return group?.children?.find((c) => c.id === childId) ?? null;
}, [referenceData, parentId, childId]);
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
@@ -75,13 +78,25 @@ export function Step5CargoDetails({
);
}, [referenceData]);
const freightTypeOptions = useMemo(
() =>
freightTypeGroups.map((g) => ({
value: g.id,
label: g.name,
})),
[freightTypeGroups],
);
const commodityOptions = useMemo(() => {
if (!referenceData?.cargo_type || !freightType) return [];
const group = referenceData.cargo_type.find(
(g) => g.code.toLowerCase() === freightType,
if (!referenceData?.cargo_type || !parentId) return [];
const group = referenceData.cargo_type.find((g) => g.id === parentId);
return (
group?.children?.map((c) => ({
value: c.id,
label: c.name,
})) ?? []
);
return group?.children?.map((c) => c.name) ?? [];
}, [referenceData, freightType]);
}, [referenceData, parentId]);
function getOverweightAlert(
type: "20ft" | "40ft",
@@ -128,7 +143,7 @@ export function Step5CargoDetails({
{/* Cargo Type */}
<div className="space-y-3">
<StepLabel>Cargo Type *</StepLabel>
<InputLabel>Cargo Type *</InputLabel>
<Controller
name="cargoType"
control={form.control}
@@ -139,7 +154,7 @@ export function Step5CargoDetails({
selected={cargoType === "container"}
onClick={() => {
field.onChange("container");
form.setValue("freightType", "", { shouldDirty: true });
form.setValue("cargoTypePath", [], { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
@@ -174,7 +189,6 @@ export function Step5CargoDetails({
{/* Weight */}
<div className="space-y-3">
<StepLabel>Weight</StepLabel>
<Controller
name="cargoWeight"
control={form.control}
@@ -198,45 +212,35 @@ export function Step5CargoDetails({
{/* Bulk freight type */}
{cargoType === "bulk" && (
<div className="space-y-3">
<StepLabel>Freight Type *</StepLabel>
<Controller
name="freightType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 sm:grid-cols-2">
{freightTypeGroups.map((group) => {
const val = group.code.toLowerCase();
return (
<OptionCard
key={group.code}
selected={freightType === val}
onClick={() => {
field.onChange(val);
form.setValue("bulkCommoditytype", "", {
shouldDirty: true,
});
}}
>
<p className="font-semibold">{group.name}</p>
</OptionCard>
);
})}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
{freightType && commodityOptions.length > 0 && (
{freightTypeOptions.length > 0 ? (
<Controller
name="bulkCommoditytype"
name="cargoTypePath.0"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Cargo type *"
label="Bulk Cargo Type *"
placeholder="Select freight type..."
data={freightTypeOptions}
/>
)}
/>
) : (
<Text size="sm" c="dimmed">
No freight types available.
</Text>
)}
{parentId && commodityOptions.length > 0 && (
<Controller
name="cargoTypePath.1"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label=""
placeholder="Select type *"
data={commodityOptions}
/>