import { createElement, type ReactNode } from "react"; import type { SidebarItem } from "@edr/ui-common"; import { IconBuildingBank, IconCashBanknote, IconChartBar, IconCoin, IconLayoutDashboard, IconReceipt2, IconRocket, IconSettings, type Icon as TablerIcon, } from "@tabler/icons-react"; import { FINANCE_PERMS } from "@/auth/permissions"; /** * The navigation tree, as data. * * Grouped by the ledger's own subjects: what is owed to EDR, what EDR owes, * what it plans to spend, and what it owns. "Ledger" holds the double-entry * core that everything else posts into. * * `gate` mirrors the server's permission check, so a reader never sees a screen * whose every request would 403 — and a group with no permitted child is not * rendered at all, rather than opening onto nothing. * * edr-hr-web carries a deliberate twin of this file. The two apps are separate * deployables and `@edr/ui-common` has ~94 consumers, most of them not Mantine * apps, so a shared abstraction would cost more than the duplication does. Keep * them in step — same as `ColorSchemeSync.tsx`. */ /** A Tabler icon component. Stored as the component, not an element, so this * file stays plain TypeScript and the icon is only constructed when rendered. * Tabler's own `Icon` type — a hand-written shape does not match it, because * its `stroke` accepts a string as well as a number. */ export type NavIcon = TablerIcon; export interface NavEntry { id: string; label: string; href: string; /** * Permission key(s) required to see this entry. Omitted = always visible. * An array is "any of". */ gate?: string | string[]; icon?: NavIcon; } export interface NavGroup { id: string; label: string; children: NavEntry[]; icon?: NavIcon; } export type NavNode = NavEntry | NavGroup; const isGroup = (node: NavNode): node is NavGroup => "children" in node; export const FINANCE_NAV: NavNode[] = [ { id: "overview", label: "Overview", href: "/", icon: IconLayoutDashboard, }, { id: "ledger", label: "Ledger", icon: IconBuildingBank, children: [ { id: "accounts", label: "Chart of accounts", href: "/accounts", gate: FINANCE_PERMS.account.view, }, { id: "journals", label: "Journals", href: "/journals", gate: FINANCE_PERMS.journal.view, }, { id: "periods", label: "Fiscal periods", href: "/periods", gate: FINANCE_PERMS.period.view, }, ], }, { id: "revenue", label: "Revenue", icon: IconCoin, children: [ { id: "receivables", label: "Receivables", href: "/receivables", gate: FINANCE_PERMS.receivable.view, }, { id: "revenue-mappings", label: "Revenue mapping", href: "/revenue-mappings", gate: FINANCE_PERMS.receivable.view, }, ], }, { id: "spend", label: "Spend", icon: IconReceipt2, children: [ { id: "payables", label: "Payables", href: "/payables", gate: FINANCE_PERMS.payable.view, }, { id: "payroll", label: "Payroll & statutory", href: "/payroll", gate: FINANCE_PERMS.payable.view, }, ], }, { id: "planning", label: "Planning", icon: IconChartBar, children: [ { id: "budgets", label: "Budgets", href: "/budgets", gate: FINANCE_PERMS.budget.view, }, { id: "cost-centers", label: "Cost centers", href: "/cost-centers", gate: FINANCE_PERMS.budget.view, }, ], }, { id: "assets", label: "Fixed assets", href: "/assets", icon: IconCashBanknote, gate: FINANCE_PERMS.asset.view, }, { id: "reports", label: "Reports", href: "/reports", icon: IconChartBar, gate: FINANCE_PERMS.report.view, }, { // Last on purpose: going live is done once, and it should not sit above the // screens used every day. id: "setup", label: "Setup", icon: IconSettings, children: [ { id: "cutover", label: "Cutover", href: "/cutover", gate: FINANCE_PERMS.period.view, icon: IconRocket, }, ], }, ]; const renderIcon = (icon?: NavIcon) => icon ? createElement(icon, { size: 18, stroke: 1.7 }) : undefined; /** * How well `href` matches `path`, or -1. Mirrors the sidebar's own rule, so the * breadcrumb and the highlighted row can never disagree: longest match wins, * boundaries are whole segments, and "/" matches only itself. */ const matchLength = (href: string, path: string) => { const target = href.toLowerCase().replace(/\/+$/, ""); const current = path.toLowerCase().replace(/\/+$/, "") || "/"; if (target === "") return current === "/" ? 1 : -1; if (current === target) return target.length; if (current.startsWith(`${target}/`)) return target.length; return -1; }; /** * Every entry the user may open, flattened, each with the group it sits in. * The command palette is built from this, so it can never offer a screen the * sidebar hides — one gate, two surfaces. */ export const visibleEntries = ( nav: NavNode[], can: (permission: string | string[]) => boolean, ): Array<{ group?: NavGroup; entry: NavEntry }> => nav.flatMap((node) => { if (!isGroup(node)) { return node.gate && !can(node.gate) ? [] : [{ entry: node }]; } return node.children .filter((child) => !child.gate || can(child.gate)) .map((entry) => ({ group: node, entry })); }); /** * The nav entry the given path belongs to, and the group holding it — the * source for the breadcrumb. A detail route resolves to its list entry * (`/journals/abc` → Journals), because that is the section the reader is in. */ export const findActive = ( nav: NavNode[], path: string, ): { group?: NavGroup; entry?: NavEntry } => { let best: { group?: NavGroup; entry?: NavEntry } = {}; let bestScore = 0; const consider = (entry: NavEntry, group?: NavGroup) => { const score = matchLength(entry.href, path); if (score > bestScore) { bestScore = score; best = { group, entry }; } }; for (const node of nav) { if (isGroup(node)) node.children.forEach((child) => consider(child, node)); else consider(node); } return best; }; /** * The tree the signed-in user may actually see, as `SidebarItem`s. * * A group is dropped entirely when none of its children are permitted, so a * header can never open onto an empty list. Group headers carry no `href` — * they toggle, and cannot navigate anywhere. */ export const visibleNav = ( nav: NavNode[], can: (permission: string | string[]) => boolean, badges: Record = {}, ): SidebarItem[] => nav.flatMap((node): SidebarItem[] => { if (!isGroup(node)) { if (node.gate && !can(node.gate)) return []; return [ { id: node.id, label: node.label, href: node.href, icon: renderIcon(node.icon), badge: badges[node.id], testId: `nav-item-${node.id}`, }, ]; } const children = node.children .filter((child) => !child.gate || can(child.gate)) .map((child) => ({ id: child.id, label: child.label, href: child.href, badge: badges[child.id], testId: `nav-item-${child.id}`, })); if (children.length === 0) return []; // A group holding exactly one permitted screen is a click that only ever // reveals one thing, so it is rendered as that screen instead — keeping the // group's icon, since the icon is what the section is recognised by. if (children.length === 1) { const only = children[0]; return [{ ...only, icon: renderIcon(node.icon) }]; } return [ { id: node.id, label: node.label, icon: renderIcon(node.icon), badge: badges[node.id], testId: `nav-group-${node.id}`, children, }, ]; });