From ed114b199906a2a7bdd83380198ab228a919ce44 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 18 Jun 2026 20:49:46 +0000 Subject: [PATCH] feat(cargo-types): add route for cargo types detail view and enhance CargoTypesPage with breadcrumbs and improved navigation --- apps/edr-freight-web/backoffice/src/App.tsx | 1 + .../src/pages/ruleEngine/CargoTypesPage.tsx | 602 ++++++++---------- 2 files changed, 258 insertions(+), 345 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f12ba9c6c..e8879ba0e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -527,6 +527,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[] = [ +/** 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 }, { 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 }; +type FormMode = { kind: "create" } | { kind: "edit"; record: CargoNode }; const CargoTypesPage = () => { const { user } = useAuth(); + const navigate = useNavigate(); + const { id: currentId } = useParams<{ id: string }>(); 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. + // One fetch of the whole (small) set; the tree, ancestry and each level are + // derived client-side so drilling between levels is instant. const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, { page: 1, pageSize: 500, @@ -91,111 +90,72 @@ const CargoTypesPage = () => { 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[] = []; + const { byId, childrenOf } = useMemo(() => { + const byId = new Map(all.map((n) => [n.id, n])); + const childrenOf = new Map(); 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); - } + const parentId = node.parentGroupId && byId.has(node.parentGroupId) ? node.parentGroupId : ""; + const key = parentId || "__root__"; + const list = childrenOf.get(key) ?? []; + list.push(node); + childrenOf.set(key, list); } - for (const list of childrenByParent.values()) { - list.sort((a, b) => orderOf(a) - orderOf(b) || str(a.cargoTypeName).localeCompare(str(b.cargoTypeName))); + for (const list of childrenOf.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 }; + return { byId, childrenOf }; }, [all]); - // Search matches a parent directly, or surfaces a parent because a child matches. + // Current node (null at root) and its ancestor chain for the breadcrumb. + const current = currentId ? byId.get(currentId) ?? null : null; + const ancestors = useMemo(() => { + const chain: CargoNode[] = []; + let node = current; + const seen = new Set(); + while (node && !seen.has(node.id)) { + chain.unshift(node); + seen.add(node.id); + node = node.parentGroupId ? byId.get(node.parentGroupId) ?? null : null; + } + return chain; + }, [current, byId]); + + const levelKey = current ? current.id : "__root__"; + const levelNodes = childrenOf.get(levelKey) ?? []; + 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]); + const visibleNodes = useMemo( + () => (term ? levelNodes.filter(matches) : levelNodes), + [levelNodes, term], + ); if (!config) return ; if (!canView) return ; + // A bad/stale :id (after data loads) → fall back to the root list. + if (!isLoading && currentId && !current) 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 atRoot = !current; + const countAtRoot = (childrenOf.get("__root__") ?? []).length; 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; + // Add always attaches to the page we're on; edit keeps the node's parent. + if (formMode?.kind === "create" && current) { + payload.parentGroupId = current.id; } - const done = () => setFormMode(null); if (formMode?.kind === "edit") { update.mutate({ id: formMode.record.id, payload }, { onSuccess: done }); @@ -204,6 +164,8 @@ const CargoTypesPage = () => { } }; + const addLabel = atRoot ? "Add category" : "Add cargo type"; + return ( {/* ── Header ─────────────────────────────────────────────── */} @@ -213,26 +175,61 @@ const CargoTypesPage = () => { withBorder style={{ background: "white", boxShadow: "0 1px 3px rgba(0,0,0,0.05)" }} > - - - - - - - + {/* Breadcrumb */} + } + mb="md" + > + navigate(BASE_PATH)}> + + + 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"} - + + + {ancestors.map((node, i) => { + const isLast = i === ancestors.length - 1; + return ( + !isLast && navigate(`${BASE_PATH}/${node.id}`)} + style={{ cursor: isLast ? "default" : "pointer" }} + > + + {str(node.cargoTypeName) || "Untitled"} + + + ); + })} + + + + + + {atRoot ? : } + + + + + {atRoot ? "Cargo Types" : str(current?.cargoTypeName) || "Untitled"} + + {!atRoot && current?.code ? ( + + {str(current.code)} + + ) : null} + {!atRoot && current?.isActive === false ? ( + + Inactive + + ) : null} + + {atRoot + ? `${countAtRoot} top-level categor${countAtRoot === 1 ? "y" : "ies"} — click one to see what's inside` + : `${levelNodes.length} cargo type${levelNodes.length === 1 ? "" : "s"} directly under this category`} + @@ -240,29 +237,33 @@ const CargoTypesPage = () => { setSearch(e.currentTarget.value)} - placeholder="Search categories or cargo…" + placeholder="Search this level…" leftSection={} - w={260} + w={240} /> {canManage && ( )} - {/* ── Tree ───────────────────────────────────────────────── */} + {/* ── Level list ─────────────────────────────────────────── */} {isLoading ? ( @@ -272,67 +273,43 @@ const CargoTypesPage = () => { Failed to load cargo types. - ) : visibleRoots.length === 0 ? ( + ) : visibleNodes.length === 0 ? ( - {term ? "No cargo types match your search" : "No cargo categories yet"} + {term + ? "Nothing matches your search" + : atRoot + ? "No cargo categories yet" + : `No cargo types under “${str(current?.cargoTypeName)}” 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)} - /> - ))} - - )} - - ); - })} + {visibleNodes.map((node, i) => ( + 0} + canManage={canManage} + onOpen={() => navigate(`${BASE_PATH}/${node.id}`)} + onEdit={() => setFormMode({ kind: "edit", record: node })} + onDelete={() => setDeleteTarget(node)} + /> + ))} )} @@ -343,10 +320,22 @@ const CargoTypesPage = () => { onOpenChange={(open) => { if (!open) setFormMode(null); }} - title={formTitle} - description={formDescription} - fields={formFields} - initialRecord={initialRecord} + title={ + formMode?.kind === "edit" + ? `Edit ${str(formMode.record.cargoTypeName)}` + : atRoot + ? "Add category" + : `Add cargo under “${str(current?.cargoTypeName)}”` + } + description={ + formMode?.kind === "edit" + ? "Update this cargo type." + : atRoot + ? "Create a top-level cargo category." + : "Create a cargo type inside this category. It's attached here automatically." + } + fields={FORM_FIELDS} + initialRecord={formMode?.kind === "edit" ? formMode.record : null} isSubmitting={create.isPending || update.isPending} onSubmit={handleSubmit} /> @@ -361,13 +350,13 @@ const CargoTypesPage = () => { > - {deleteTarget && (childrenByParent.get(deleteTarget.id)?.length ?? 0) > 0 ? ( + {deleteTarget && (childrenOf.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? + has {childrenOf.get(deleteTarget!.id)?.length} cargo type(s) under it. Deleting it + leaves them without a category. Continue? ) : ( <> @@ -388,9 +377,7 @@ const CargoTypesPage = () => { loading={remove.isPending} onClick={() => { if (!deleteTarget) return; - remove.mutate(deleteTarget.id, { - onSuccess: () => setDeleteTarget(null), - }); + remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) }); }} > Delete @@ -402,197 +389,122 @@ const CargoTypesPage = () => { ); }; -// ── Parent (category) row ────────────────────────────────────────────────── -interface ParentRowProps { - parent: CargoNode; +// ── A single cargo row — drills into its own page on click ────────────────── +interface CargoRowProps { + node: CargoNode; childCount: number; - open: boolean; topBorder: boolean; canManage: boolean; - onToggle: () => void; + onOpen: () => void; onEdit: () => void; onDelete: () => void; - onAddChild: () => void; - children: React.ReactNode; } -function ParentRow({ - parent, +function CargoRow({ + node, childCount, - open, topBorder, canManage, - onToggle, + onOpen, 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; +}: CargoRowProps) { + const inactive = node.isActive === false; + const hasChildren = childCount > 0; return ( { + e.currentTarget.style.background = "var(--mantine-color-teal-0)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = ""; + }} > - - - - {str(child.cargoTypeName) || "Untitled"} - - {child.code ? ( - - {str(child.code)} - - ) : null} - {child.requiresDirectorApproval ? ( - - } - > - Approval - - - ) : null} - {child.showFreeTextBox ? ( - - } - > - Free text - - - ) : null} - {inactive ? ( - - Inactive - - ) : null} - - - {canManage && ( - - - - - - - + + + + {hasChildren ? : } + + + + + {str(node.cargoTypeName) || "Untitled"} + + {node.code ? ( + + {str(node.code)} + + ) : null} + {node.requiresDirectorApproval ? ( + + } + > + Approval + + + ) : null} + {node.showFreeTextBox ? ( + + } + > + Free text + + + ) : null} + {inactive ? ( + + Inactive + + ) : null} + + + {hasChildren + ? `${childCount} cargo type${childCount === 1 ? "" : "s"} inside` + : "No cargo types inside yet — open to add"} + + - )} + + + + {canManage && ( + <> + + + + + + + + )} + + + + ); }