mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Muluhabt ERP modules
This commit is contained in:
307
apps/finance-web/src/shared/nav/nav-model.ts
Normal file
307
apps/finance-web/src/shared/nav/nav-model.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
});
|
||||
Reference in New Issue
Block a user