Files
edr-platform/apps/finance-web/src/shared/nav/nav-model.ts
2026-08-25 00:11:39 +03:00

308 lines
8.0 KiB
TypeScript

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<string, ReactNode> = {},
): 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,
},
];
});