/** * The navigation table. * * Lifted out of Shell.tsx because four things now read it — the sidebar, the * phone tab bar, the command palette and the header's page title — and a table * that four consumers each filter differently is a table that ends up * duplicated. * * Visibility is a *capability* question, not a cosmetic one. The convention in * this codebase is to disable a control rather than hide it, so that the * interface tells you the same story regardless of who you are. Navigation is * the exception: a destination someone cannot use is not a disabled control, * it is a page that answers 403, and offering it is worse than omitting it. */ import { BookUser, Boxes, Building2, CalendarClock, FileSpreadsheet, FileText, GraduationCap, LayoutDashboard, Server, Settings, ShieldCheck, Sparkles, Target, TrendingUp, Users, type LucideIcon, } from 'lucide-react'; import type { Capability, Team } from '@pig/core'; import { canAny, type PermissionIdentity } from './permissions'; export const NAV_GROUPS = ['Workspace', 'Intelligence', 'Marketplace', 'Records', 'Control'] as const; export type NavGroup = (typeof NAV_GROUPS)[number]; /** * The heading a group prints above its rows in the sidebar, or `null` for a * group that leads the list and needs none. * * Workspace is that group. It holds one row — Piggy — and a "WORKSPACE" * heading over a single row named Piggy says nothing the row does not; worse, * it makes the front door look like one section among five rather than the * thing the product opens on. Rendered unlabelled and followed by a rule, it * reads as what it is. The command palette still groups by the same name, * where a heading is doing real work because the list there is flat. */ export const NAV_GROUP_HEADING: Record = { Workspace: null, Intelligence: 'Intelligence', Marketplace: 'Marketplace', Records: 'Records', Control: 'Control', }; export interface NavItem { to: string; label: string; icon: LucideIcon; shortcut?: string; group: NavGroup; /** Shown in the phone tab bar. Space there is scarce, so only five fit. */ primary?: boolean; /** * Hide the item unless this capability is granted somewhere. Absent means * the page is readable by any member — which includes Settings, where the * appearance controls and Sign out live for everybody, admin or not. */ requires?: Capability; /** Narrows `requires` to one team, where the API enforces one. */ requiresTeam?: Team; } export const NAV: NavItem[] = [ /* * First row, own group, and the destination `/` redirects to: Piggy is where * the product starts now. It was the fourth row of Intelligence, which put * the agent below three reports — a filing that made sense when Piggy could * only read and answer, and stopped making sense the moment it could act. * * Sparkles rather than a chat bubble because the header's Piggy control * already uses Sparkles: the rail row and the header button open the same * agent on two surfaces, and giving them one glyph is what says so. Nothing * else in this table uses it, which is the constraint that matters — the * sidebar collapses to icons alone, and two rows sharing a glyph are two * rows you have to expand the sidebar to tell apart. */ { to: '/piggy', label: 'Piggy', icon: Sparkles, group: 'Workspace', primary: true }, // Still first in Intelligence and still in the phone tab bar. Losing `/` cost // it a URL, not its prominence: it is one click from anywhere, and it remains // the page an exec opens to see whether the business is working. { to: '/overview', label: 'Overview', icon: LayoutDashboard, group: 'Intelligence', primary: true }, { to: '/growth', label: 'Growth', icon: Target, group: 'Intelligence' }, { to: '/calendar', label: 'Calendar', icon: CalendarClock, group: 'Intelligence' }, { to: '/learn', label: 'Learn', icon: GraduationCap, group: 'Intelligence' }, { to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true }, { to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true }, { to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true }, { to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true }, // BookUser rather than a second Building2: the sidebar collapses to icons // only, and Demand already owns the office block. Two rows sharing a glyph // are two rows you have to expand the sidebar to tell apart. It is also the // truer icon — this page is the directory of accounts *and* their people. { to: '/accounts', label: 'Accounts', icon: BookUser, group: 'Records' }, { to: '/contracts', label: 'Contracts', icon: FileText, group: 'Records' }, { to: '/imports', label: 'Import', icon: FileSpreadsheet, group: 'Records', requires: 'data:import', }, { to: '/team', label: 'Team', icon: Users, group: 'Control' }, { to: '/facts', label: 'Fact review', icon: ShieldCheck, group: 'Control', // The API gates fact review on data:import for the research team // specifically (routes/facts.ts), so the nav has to ask the same question. requires: 'data:import', requiresTeam: 'research', }, { to: '/settings', label: 'Settings', icon: Settings, group: 'Control' }, ]; export function visibleNav(identity: PermissionIdentity | undefined): NavItem[] { return NAV.filter((item) => { if (!item.requires) return true; if (!item.requiresTeam) return canAny(identity, item.requires); return Boolean( identity && identity.permissions.some( (grant) => grant.capability === item.requires && (grant.team === null || grant.team === item.requiresTeam), ), ); }); } /** The nav entry a pathname belongs to, for the header title and active state. */ export function activeNavItem(items: readonly NavItem[], pathname: string): NavItem | undefined { return items.find((item) => item.to === '/' ? pathname === '/' : pathname === item.to || pathname.startsWith(`${item.to}/`), ); }