diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 0c61cc445..f12ba9c6c 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -44,6 +44,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
+import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
@@ -525,6 +526,7 @@ const App = () => {
}
/>
+ } />
} />
(v == null ? "" : String(v));
+const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
+
+/** Fields for the create/edit form. Parent-group select is added contextually. */
+const PARENT_FORM_FIELDS: FormFieldDef[] = [
+ { name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
+ { name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
+ { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
+ { name: "isActive", label: "Active", type: "boolean" },
+];
+
+type FormMode =
+ | { kind: "create-parent" }
+ | { kind: "create-child"; parent: CargoNode }
+ | { kind: "edit"; record: CargoNode };
+
+const CargoTypesPage = () => {
+ const { user } = useAuth();
+ const config = getRuleEngineResource(CARGO_SLUG);
+
+ const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
+ const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
+
+ // Pull the whole set once and build the tree on the client — cargo types are a
+ // small, slow-changing config set, so a single fetch keeps the UX instant.
+ const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, {
+ page: 1,
+ pageSize: 500,
+ sortBy: "displayOrder",
+ sortOrder: "ASC",
+ });
+
+ const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
+
+ const [search, setSearch] = useState("");
+ const [expanded, setExpanded] = useState>(new Set());
+ const [formMode, setFormMode] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const all = (data?.data ?? []) as CargoNode[];
+
+ // Group children under their parents; anything without a (known) parent is a root.
+ const { roots, childrenByParent } = useMemo(() => {
+ const byId = new Map(all.map((n) => [n.id, n]));
+ const childrenByParent = new Map();
+ const roots: CargoNode[] = [];
+ for (const node of all) {
+ const parentId = node.parentGroupId ?? null;
+ if (parentId && byId.has(parentId)) {
+ const list = childrenByParent.get(parentId) ?? [];
+ list.push(node);
+ childrenByParent.set(parentId, list);
+ } else {
+ roots.push(node);
+ }
+ }
+ for (const list of childrenByParent.values()) {
+ list.sort((a, b) => orderOf(a) - orderOf(b) || str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)));
+ }
+ roots.sort((a, b) => orderOf(a) - orderOf(b) || str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)));
+ return { roots, childrenByParent };
+ }, [all]);
+
+ // Search matches a parent directly, or surfaces a parent because a child matches.
+ const term = search.trim().toLowerCase();
+ const matches = (n: CargoNode) =>
+ !term ||
+ str(n.cargoTypeName).toLowerCase().includes(term) ||
+ str(n.code).toLowerCase().includes(term);
+
+ const visibleRoots = useMemo(() => {
+ if (!term) return roots;
+ return roots.filter(
+ (p) => matches(p) || (childrenByParent.get(p.id) ?? []).some(matches),
+ );
+ }, [roots, childrenByParent, term]);
+
+ if (!config) return ;
+ if (!canView) return ;
+
+ const toggle = (id: string) =>
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+
+ const parentCount = roots.length;
+ const childCount = all.length - roots.length;
+
+ // ── form wiring ──────────────────────────────────────────────────────────
+ const formFields: FormFieldDef[] =
+ formMode?.kind === "edit" && !formMode.record.parentGroupId
+ ? // editing a parent → allow re-parenting via a select of OTHER roots
+ [
+ PARENT_FORM_FIELDS[0],
+ {
+ name: "parentGroupId",
+ label: "Parent group",
+ type: "select",
+ optional: true,
+ placeholder: "Top-level (no parent)",
+ options: [
+ { label: "None (top level)", value: RULE_ENGINE_SELECT_NONE },
+ ...roots
+ .filter((r) => r.id !== formMode.record.id)
+ .map((r) => ({ label: str(r.cargoTypeName), value: r.id })),
+ ],
+ },
+ ...PARENT_FORM_FIELDS.slice(1),
+ ]
+ : // creating a parent, creating a child, or editing a child → no parent picker
+ PARENT_FORM_FIELDS;
+
+ const initialRecord: RuleEngineRecord | null =
+ formMode?.kind === "edit" ? formMode.record : null;
+
+ const formTitle =
+ formMode?.kind === "create-parent"
+ ? "Add cargo type"
+ : formMode?.kind === "create-child"
+ ? `Add cargo under “${str(formMode.parent.cargoTypeName)}”`
+ : formMode?.kind === "edit"
+ ? `Edit ${str(formMode.record.cargoTypeName)}`
+ : "";
+
+ const formDescription =
+ formMode?.kind === "create-parent"
+ ? "Create a top-level cargo category. You can add cargo types under it afterwards."
+ : formMode?.kind === "create-child"
+ ? "Create a cargo type inside this category."
+ : "Update this cargo type.";
+
+ const handleSubmit = (values: Record) => {
+ // Force the parent context for child creates; the form has no parent picker.
+ const payload: Record = { ...values };
+ if (formMode?.kind === "create-child") {
+ payload.parentGroupId = formMode.parent.id;
+ }
+
+ const done = () => setFormMode(null);
+ if (formMode?.kind === "edit") {
+ update.mutate({ id: formMode.record.id, payload }, { onSuccess: done });
+ } else {
+ create.mutate(payload, { onSuccess: done });
+ }
+ };
+
+ return (
+
+ {/* ── Header ─────────────────────────────────────────────── */}
+
+
+
+
+
+
+
+
+ Cargo Types
+
+
+ Organise freight cargo into categories and the cargo types within them.
+
+
+
+ {parentCount} categor{parentCount === 1 ? "y" : "ies"}
+
+
+ {childCount} cargo type{childCount === 1 ? "" : "s"}
+
+
+
+
+
+
+ setSearch(e.currentTarget.value)}
+ placeholder="Search categories or cargo…"
+ leftSection={}
+ w={260}
+ />
+ {canManage && (
+ }
+ onClick={() => setFormMode({ kind: "create-parent" })}
+ >
+ Add category
+
+ )}
+
+
+
+
+ {/* ── Tree ───────────────────────────────────────────────── */}
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+ Failed to load cargo types.
+
+ ) : visibleRoots.length === 0 ? (
+
+
+
+
+
+ {term ? "No cargo types match your search" : "No cargo categories yet"}
+
+ {!term && canManage && (
+ }
+ onClick={() => setFormMode({ kind: "create-parent" })}
+ >
+ Add your first category
+
+ )}
+
+ ) : (
+
+ {visibleRoots.map((parent, i) => {
+ const kids = childrenByParent.get(parent.id) ?? [];
+ const isOpen = expanded.has(parent.id) || Boolean(term);
+ const shownKids = term ? kids.filter(matches) : kids;
+ return (
+ 0}
+ canManage={canManage}
+ onToggle={() => toggle(parent.id)}
+ onEdit={() => setFormMode({ kind: "edit", record: parent })}
+ onDelete={() => setDeleteTarget(parent)}
+ onAddChild={() => {
+ setExpanded((prev) => new Set(prev).add(parent.id));
+ setFormMode({ kind: "create-child", parent });
+ }}
+ >
+ {shownKids.length === 0 ? (
+
+ No cargo types in this category yet.
+
+ ) : (
+
+ {shownKids.map((child) => (
+ setFormMode({ kind: "edit", record: child })}
+ onDelete={() => setDeleteTarget(child)}
+ />
+ ))}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ {/* ── Create / edit dialog ───────────────────────────────── */}
+ {
+ if (!open) setFormMode(null);
+ }}
+ title={formTitle}
+ description={formDescription}
+ fields={formFields}
+ initialRecord={initialRecord}
+ isSubmitting={create.isPending || update.isPending}
+ onSubmit={handleSubmit}
+ />
+
+ {/* ── Delete confirm ─────────────────────────────────────── */}
+ setDeleteTarget(null)}
+ title="Delete cargo type?"
+ centered
+ size="sm"
+ >
+
+
+ {deleteTarget && (childrenByParent.get(deleteTarget.id)?.length ?? 0) > 0 ? (
+ <>
+
+ {str(deleteTarget?.cargoTypeName)}
+ {" "}
+ has {childrenByParent.get(deleteTarget!.id)?.length} cargo type(s) under it.
+ Deleting it leaves them without a category. Continue?
+ >
+ ) : (
+ <>
+ This will delete{" "}
+
+ {str(deleteTarget?.cargoTypeName)}
+
+ .
+ >
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+// ── Parent (category) row ──────────────────────────────────────────────────
+interface ParentRowProps {
+ parent: CargoNode;
+ childCount: number;
+ open: boolean;
+ topBorder: boolean;
+ canManage: boolean;
+ onToggle: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onAddChild: () => void;
+ children: React.ReactNode;
+}
+
+function ParentRow({
+ parent,
+ childCount,
+ open,
+ topBorder,
+ canManage,
+ onToggle,
+ onEdit,
+ onDelete,
+ onAddChild,
+ children,
+}: ParentRowProps) {
+ const inactive = parent.isActive === false;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {str(parent.cargoTypeName) || "Untitled"}
+
+ {parent.code ? (
+
+ {str(parent.code)}
+
+ ) : null}
+ {inactive ? (
+
+ Inactive
+
+ ) : null}
+
+
+ {childCount} cargo type{childCount === 1 ? "" : "s"}
+
+
+
+
+
+
+ {canManage && (
+
+ }
+ onClick={onAddChild}
+ >
+ Add
+
+
+ )}
+ {canManage && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ {children}
+
+
+ );
+}
+
+// ── Child (cargo type) row ─────────────────────────────────────────────────
+interface ChildRowProps {
+ child: CargoNode;
+ canManage: boolean;
+ onEdit: () => void;
+ onDelete: () => void;
+}
+
+function ChildRow({ child, canManage, onEdit, onDelete }: ChildRowProps) {
+ const inactive = child.isActive === false;
+ return (
+
+
+
+
+ {str(child.cargoTypeName) || "Untitled"}
+
+ {child.code ? (
+
+ {str(child.code)}
+
+ ) : null}
+ {child.requiresDirectorApproval ? (
+
+ }
+ >
+ Approval
+
+
+ ) : null}
+ {child.showFreeTextBox ? (
+
+ }
+ >
+ Free text
+
+
+ ) : null}
+ {inactive ? (
+
+ Inactive
+
+ ) : null}
+
+
+ {canManage && (
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+export default CargoTypesPage;