mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 02:30:55 +00:00
308 lines
8.0 KiB
TypeScript
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,
|
|
},
|
|
];
|
|
});
|