import { BadgeDollarSign, Calculator, ClipboardPenLine, FlaskConical, Gauge, Layers, LayoutDashboard, Link2, ListOrdered, Package, Plug, ShieldCheck, ShoppingCart, SlidersHorizontal, Settings, Tags, TrendingUp, Users } from 'lucide-svelte'; import type { ComponentType } from 'svelte'; import { featureFlags } from '$lib/features'; import { canOpenDashboard } from '$lib/workspace-access'; import type { AppSession } from '$lib/session'; export type SearchItem = { href: string; label: string; description: string; keywords: string; }; export type NavItem = { href: string; label: string; shortLabel: string; icon: ComponentType; moduleKey?: string; badge?: string; /** * Highlight this row only on an exact pathname match instead of a prefix * match. Needed for parent routes like `/ordering/manage` that are a prefix * of their siblings (`/ordering/manage/products`). */ exact?: boolean; /** * Optional third-level submenu. A child with `children` renders as its own * collapsible row inside a group (e.g. Integrations → Xero). The row stays a * link to its own `href`; a chevron toggles the nested list. */ children?: NavItem[]; }; export type FooterLink = { href: string; label: string; shortLabel: string; icon: ComponentType; }; /** * A collapsible family of related modules in the primary rail. Groups keep the * top level short as more modules ship: a new costing tool becomes another child * here rather than another peer in a flat stack. */ export type NavGroup = { id: string; label: string; icon: ComponentType; children: NavItem[]; /** * When set, the group header is itself a link (clicking it navigates here) * while a separate chevron still toggles the child list. Used by Order * Management: clicking the header lands on the order queue. */ href?: string; /** Exact-match the header link's active state (see NavItem.exact). */ exact?: boolean; }; /** The rail is a sequence of standalone items and collapsible groups. */ export type NavEntry = | { kind: 'item'; item: NavItem } | { kind: 'group'; group: NavGroup }; export type Crumb = { label: string; href?: string; }; export type PageMeta = { title: string; category: string; icon: ComponentType; }; export const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: LayoutDashboard, moduleKey: 'dashboard' }; export const mixCalculatorItem: NavItem = { href: '/mix-calculator', label: 'Mix Calculator', shortLabel: 'MC', icon: Calculator, moduleKey: 'mix_calculator' }; export const productCostingItem: NavItem = { href: '/product-costing', label: 'Product Costing', shortLabel: 'PC', icon: BadgeDollarSign, moduleKey: 'products', badge: 'Alpha' }; export const editorItem: NavItem = { href: '/editor', label: 'Mix Editor', shortLabel: 'ED', icon: ClipboardPenLine, moduleKey: 'products', badge: 'test' }; export const ingredientsEditorItem: NavItem = { href: '/ingredients', label: 'Ingredients Editor', shortLabel: 'IE', icon: FlaskConical, moduleKey: 'products', badge: 'test' }; export const reportingItem: NavItem = { href: '/reporting', label: 'Reporting', shortLabel: 'RP', icon: TrendingUp, moduleKey: 'products' }; export const throughputItem: NavItem = { href: '/throughput', label: 'Throughput', shortLabel: 'OT', icon: Gauge, moduleKey: 'operations_throughput' }; export const orderingItem: NavItem = { href: '/ordering', label: 'Ordering', shortLabel: 'OR', icon: ShoppingCart, moduleKey: 'ordering' }; /** Third-level submenu under Integrations. Each connected system is its own row. */ export const integrationsChildren: NavItem[] = [ { href: '/ordering/manage/integrations/xero', label: 'Xero', shortLabel: 'XE', icon: Link2, moduleKey: 'ordering' } ]; /** * Children of the internal "Order Management" family. The first entry points at * the management root (`/ordering/manage`) which renders the order queue, so it * needs `exact` matching to avoid lighting up on its sibling routes. Integrations * is itself a parent: it links to the integrations landing and expands to its * connected systems (Xero) as a third sidebar layer. */ export const orderingManageChildren: NavItem[] = [ { href: '/ordering/manage', label: 'Orders', shortLabel: 'OQ', icon: ListOrdered, moduleKey: 'ordering', exact: true }, { href: '/ordering/manage/products', label: 'Products', shortLabel: 'PR', icon: Package, moduleKey: 'ordering' }, { href: '/ordering/manage/customers', label: 'Customers', shortLabel: 'CU', icon: Users, moduleKey: 'ordering' }, { href: '/ordering/manage/pricing', label: 'Pricing', shortLabel: 'PX', icon: Tags, moduleKey: 'ordering' }, { href: '/ordering/manage/settings', label: 'Settings', shortLabel: 'ST', icon: SlidersHorizontal, moduleKey: 'ordering' }, { href: '/ordering/manage/integrations', label: 'Integrations', shortLabel: 'IN', icon: Plug, moduleKey: 'ordering', exact: true, children: integrationsChildren } ]; /** The collapsible Order Management family for internal staff. */ export const orderingManageGroup: NavGroup = { id: 'ordering', label: 'Order Management', icon: ShoppingCart, href: '/ordering/manage', exact: true, children: orderingManageChildren }; export const workingDocumentItems: NavItem[] = [ // Mix Master remains available through the existing route and access logic, // but is temporarily hidden from the sidebar. ]; export const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', icon: ShieldCheck, moduleKey: 'client_access' }; export const clientNavigationItems: NavItem[] = [ dashboardItem, mixCalculatorItem, productCostingItem, throughputItem, editorItem, ingredientsEditorItem, accessControlItem ]; export const footerLinks: FooterLink[] = []; export const baseSearchItems: SearchItem[] = [ { href: '/product-costing', label: 'Open Product Costing', description: 'Maintain product costing records, assumptions, and calculated pricing.', keywords: 'alpha product costing pricing finished delivered distributor wholesale margin spreadsheet' }, { href: '/editor', label: 'Open Mix Editor', description: 'Edit mix names, status, and ingredients from one table.', keywords: 'editor mixes clients names status ingredients recipe table phf horse manning' }, { href: '/ingredients', label: 'Open Ingredients Editor', description: 'Curate the raw material ingredients available to mixes.', keywords: 'ingredients editor raw materials supplier unit kg per unit catalogue mixes' }, { href: '/', label: 'Open Dashboard', description: 'Jump to the Hunter Premium Produce workspace summary.', keywords: 'hunter premium produce overview dashboard workspace home' }, { href: '/mixes', label: 'Open Mix Master', description: 'Browse saved mixes and their costing outputs.', keywords: 'mix master mixes recipes spreadsheet' }, { href: '/mixes/new', label: 'Create New Mix', description: 'Start a new costing worksheet for Hunter Premium Produce.', keywords: 'new mix create worksheet hunter premium produce formula' }, ...(featureFlags.mixCalculatorSessionHistory ? [ { href: '/mix-calculator', label: 'Open Mix Calculator', description: 'Review saved production sessions and batch calculations.', keywords: 'mix calculator production sessions batch bags client product' } ] : []), { href: '/mix-calculator', label: 'Open Mix Calculator', description: 'Run a new client-specific mix calculation session.', keywords: 'new mix calculator session client batch size product bags print' }, { href: '/reporting', label: 'Open Reporting', description: 'View raw material costs, mix summaries, product pricing, and data quality reports.', keywords: 'reporting reports raw materials mix cost product pricing data quality price review' }, { href: '/settings', label: 'Open Workspace Settings', description: 'Review account details and workspace preferences.', keywords: 'settings account preferences profile workspace' }, ]; /** * Assemble the grouped primary rail from already access-filtered items. * Callers pass only the modules the current session may see; empty families * collapse away so a role with one costing tool never gets an empty group. * * Workflow-family layout: Dashboard, then an "Operations" group (the mix * calculator and throughput) and a "Costing" group (product costing, mix and * ingredient editors, and master tools), then Ordering and Insights modules. */ export function buildClientNavEntries(visible: { dashboard?: NavItem | null; operations: NavItem[]; costing: NavItem[]; ordering?: NavEntry | null; reporting?: NavItem | null; }): NavEntry[] { const entries: NavEntry[] = []; if (visible.dashboard) { entries.push({ kind: 'item', item: visible.dashboard }); } if (visible.operations.length) { entries.push({ kind: 'group', group: { id: 'operations', label: 'Operations', icon: Layers, children: visible.operations } }); } if (visible.costing.length) { entries.push({ kind: 'group', group: { id: 'costing', label: 'Costing', icon: BadgeDollarSign, children: visible.costing } }); } if (visible.ordering) { entries.push(visible.ordering); } if (visible.reporting) { entries.push({ kind: 'item', item: visible.reporting }); } return entries; } /** True when a row or any of its nested children matches the current route. */ function itemOrChildActive(item: NavItem, pathname: string): boolean { if (matchesRoute(item.href, pathname, item.exact)) return true; return item.children?.some((child) => matchesRoute(child.href, pathname, child.exact)) ?? false; } /** True when any of a group's children (or grandchildren) matches the route. */ export function groupHasActiveChild(group: NavGroup, pathname: string) { return group.children.some((child) => itemOrChildActive(child, pathname)); } export function matchesRoute(href: string, pathname: string, exact = false) { if (href === '/') return pathname === '/'; if (exact) return pathname === href; return pathname.startsWith(href); } /** * Resolve the deepest Order Management section row for a path, descending into * third-level submenus (Integrations → Xero) so headers and breadcrumbs name the * actual page rather than the parent. Returns null for the management root. */ export function findOrderingSection(pathname: string): NavItem | null { for (const child of orderingManageChildren) { // Check grandchildren first so a nested page (Xero) wins over its parent. for (const grandchild of child.children ?? []) { if (matchesRoute(grandchild.href, pathname, grandchild.exact)) return grandchild; } if (matchesRoute(child.href, pathname, child.exact)) return child; } return null; } export function pageTitle(pathname: string) { return pageMeta(pathname).title; } export function pageCategory(pathname: string) { return pageMeta(pathname).category; } export function pageMeta(pathname: string): PageMeta { if (pathname === '/') { return { title: 'Dashboard', category: 'Overview', icon: dashboardItem.icon }; } if (pathname.startsWith('/ordering/manage')) { const section = findOrderingSection(pathname); return { title: section?.label ?? 'Orders', category: 'Order Management', icon: section?.icon ?? orderingManageGroup.icon }; } if (pathname.startsWith('/ordering')) { return { title: 'Ordering', category: 'Ordering', icon: orderingItem.icon }; } if (pathname.startsWith('/throughput/add')) { return { title: 'Add Entry', category: 'Operations', icon: throughputItem.icon }; } if (pathname.startsWith('/throughput')) { return { title: throughputItem.label, category: 'Operations', icon: throughputItem.icon }; } if (pathname.startsWith('/reporting')) { return { title: reportingItem.label, category: 'Insights', icon: reportingItem.icon }; } if (pathname.startsWith('/mix-calculator')) { return { title: mixCalculatorItem.label, category: 'Operations', icon: mixCalculatorItem.icon }; } if (pathname.startsWith('/product-costing')) { return { title: productCostingItem.label, category: 'Costing', icon: productCostingItem.icon }; } if (pathname.startsWith('/editor')) { return { title: editorItem.label, category: 'Costing', icon: editorItem.icon }; } if (pathname.startsWith('/ingredients')) { return { title: ingredientsEditorItem.label, category: 'Costing', icon: ingredientsEditorItem.icon }; } if (pathname.startsWith('/raw-materials')) { return { title: 'Raw Materials', category: 'Operations', icon: FlaskConical }; } if (pathname.startsWith('/products')) { return { title: 'Products', category: 'Operations', icon: Package }; } if (pathname.startsWith('/mixes/new')) { return { title: 'New Mix', category: 'Operations', icon: ClipboardPenLine }; } if (pathname.startsWith('/mixes')) { return { title: 'Mix Master', category: 'Operations', icon: Layers }; } if (pathname.startsWith('/scenarios')) { return { title: 'Scenarios', category: 'Operations', icon: Layers }; } if (pathname.startsWith('/client-access')) { return { title: accessControlItem.label, category: 'Administration', icon: accessControlItem.icon }; } if (pathname.startsWith('/settings')) { return { title: 'Settings', category: 'Workspace', icon: Settings }; } return { title: 'Workspace', category: 'Workspace', icon: LayoutDashboard }; } export function clientBreadcrumbs(pathname: string, session?: AppSession | null): Crumb[] { const crumbs: Crumb[] = []; if (canOpenDashboard(session)) { crumbs.push({ label: 'Workspace', href: '/' }); } if (pathname === '/') { return crumbs.length ? [...crumbs, { label: 'Dashboard' }] : [{ label: 'Dashboard' }]; } if (pathname.startsWith('/mix-calculator')) { return [...crumbs, { label: 'Mix Calculator' }]; } if (pathname.startsWith('/product-costing')) { return [...crumbs, { label: 'Product Costing' }]; } if (pathname.startsWith('/editor')) { return [...crumbs, { label: 'Mix Editor' }]; } if (pathname.startsWith('/mixes')) { return [...crumbs, { label: 'Mix Master' }]; } if (pathname.startsWith('/throughput')) { const base: Crumb[] = [...crumbs, { label: 'Throughput', href: '/throughput' }]; if (pathname === '/throughput/add') return [...base, { label: 'Add Entry' }]; if (pathname === '/throughput') return base.slice(0, -1).concat([{ label: 'Throughput' }]); return base; } if (pathname.startsWith('/ordering/manage')) { const section = findOrderingSection(pathname); if (!section || section.href === '/ordering/manage') { return [...crumbs, { label: 'Order Management' }]; } const chain: Crumb[] = [...crumbs, { label: 'Order Management', href: '/ordering/manage' }]; // When the section is a nested grandchild (e.g. Xero), include its parent // (Integrations) as an intermediate crumb. const parent = orderingManageChildren.find((c) => c.children?.includes(section)); if (parent) chain.push({ label: parent.label, href: parent.href }); chain.push({ label: section.label }); return chain; } const sectionMap: Record = { '/raw-materials': 'Raw Materials', '/product-costing': 'Product Costing', '/products': 'Products', '/scenarios': 'Scenarios', '/client-access': 'Client Access', '/reporting': 'Reporting', '/settings': 'Settings', '/throughput': 'Throughput' }; const section = sectionMap[pathname]; if (section) return [...crumbs, { label: section }]; return [...crumbs, { label: pageTitle(pathname) }]; }