From aa105a5e76b734353d88ffa873065cfa68c4b163 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 18 Jun 2026 20:19:40 +0000 Subject: [PATCH] feat(cargo-types): add CargoTypesPage for managing freight cargo categories and types --- apps/edr-freight-web/backoffice/src/App.tsx | 2 + .../src/pages/ruleEngine/CargoTypesPage.tsx | 600 ++++++++++++++++++ 2 files changed, 602 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx 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 && ( + + )} + + + + + {/* ── Tree ───────────────────────────────────────────────── */} + + {isLoading ? ( + + + + ) : isError ? ( + + Failed to load cargo types. + + ) : visibleRoots.length === 0 ? ( + + + + + + {term ? "No cargo types match your search" : "No cargo categories yet"} + + {!term && canManage && ( + + )} + + ) : ( + + {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 && ( + + + + )} + {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;