'use client' import { useEffect, useState, useRef } from 'react' import { NavLink } from './NavLink' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { LayoutDashboard, Home, ReceiptText, Users, ArrowLeftRight, BookOpen, ListTree, BarChart3, Settings, LogOut, Upload, Inbox, Menu, X, HelpCircle, Building2, Wallet, TrendingUp, ClipboardCheck, HandCoins, Package, Tag, Tags, ChevronRight, Sparkles, Percent, Landmark, Scale, CalendarClock, CalendarRange, FileCheck, FileSpreadsheet, ScrollText, PanelLeft, PanelLeftClose, Library, BookCheck, Briefcase, ArrowLeft, Workflow, FolderArchive, ShoppingCart, Car, ClipboardList, } from 'lucide-react' import { getBranding } from '@/lib/branding/service' import { BrandHomeLink } from '@/components/branding/BrandHomeLink' import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { resolveIcon } from '@/lib/extensions/icon-resolver' import { resetAnalyticsIdentity } from '@/lib/analytics/reset' import { SupportLink } from '@/components/ui/support-link' import CompanySwitcher from '@/components/dashboard/CompanySwitcher' import UserMenu from '@/components/dashboard/UserMenu' import SubscriptionTouchpoint from '@/components/billing/SubscriptionTouchpoint' import AgentAvatar from '@/components/agent/AgentAvatar' import { useAgentSheet } from '@/components/agent/AgentSheetProvider' import { useCompany } from '@/contexts/CompanyContext' import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase' import { useWorklistBadges } from '@/lib/hooks/use-worklist-badges' import { persistUiState } from '@/lib/ui-state/client' import { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys' import type { EntityType, UserUiState } from '@/types' void _ENABLED_EXTENSION_IDS interface ExtensionNavItem { href: string label: string icon: string } interface DashboardNavProps { companyName: string entityType: EntityType // Whether the company has registered as an employer (company_settings. // pays_salaries). Drives visibility of the payroll (Personal) section for // non-aktiebolag, notably an enskild firma that hires staff. See #782. paysSalaries?: boolean // Whether the dimensions register (company_settings.dimensions_enabled) is // switched on. Drives visibility of the Kostnadsställen & projekt row: // same mechanism as paysSalaries: fetched by the dashboard layout. dimensionsEnabled?: boolean // Whether kundorder (company_settings.sales_orders_enabled) is switched on. // Drives visibility of the Kundorder row: same mechanism as // dimensionsEnabled, fetched by the dashboard layout. salesOrdersEnabled?: boolean // Whether the company has a webshop hooked up (active WooCommerce/Shopify // connection, or existing webshop_orders rows). Drives visibility of the // Order row: same mechanism as paysSalaries, fetched by the layout. hasWebshop?: boolean // Whether the Körjournal row shows: the company_settings.mileage_enabled // toggle OR existing mileage_trips rows (trips created via API/MCP must // stay reachable). Computed by the dashboard layout. hasMileage?: boolean isSandbox?: boolean extensionNavItems?: ExtensionNavItem[] // Signed-in user's full name + email: drives the bottom-left account // popover trigger so the user can see WHO they're logged in as, // distinct from the active COMPANY shown by CompanySwitcher up top. userName?: string | null userEmail?: string | null // Server-read user_preferences.ui_state: seeds sidebar collapse + fold // state so the first client render matches the server-stamped width. initialUiState?: UserUiState } type NavLabelKey = | 'dashboard' | 'home' | 'assistant' | 'agent_knowledge' | 'kpi' | 'invoice_inbox' | 'invoices' | 'sales_orders' | 'webshop_orders' | 'customers' | 'articles' | 'supplier_invoices' | 'suppliers' | 'review' | 'transactions' | 'reconciliation' | 'bookkeeping' | 'chart_of_accounts' | 'dimensions' | 'assets' | 'reports' | 'import' | 'salary' | 'mileage' | 'employees' | 'vat_declaration' | 'skattekonto' | 'deadlines' | 'periodiseringar' | 'year_end' | 'bokslutsbilagor' | 'annual_report' | 'income_declaration' | 'help' | 'settings' | 'clients' | 'automations' | 'back_to_clients' // Nav layout (July 2026, UI-migration concept, dev_docs/ui_migration_plan.md // PR 2): same routes, concept structure. // top of rail : collapse toggle (64px icon rail when collapsed; // state persists in user_preferences.ui_state). // top section : flat, no header: Hem, Assistent (Flöden joins // when the flow engine exists). // four groups : static headers (Arbeta, Analys, Data, // Skatt & bokslut); the Register (Data) and // Bokslut (Skatt) sub-lists are animated folds. // bottom user block : sticky; avatar + name + active company, opens // the upward UserMenu with the company-switcher // flyout, account links and logout. // Help + Settings are NOT in `navItems`; they live in the user menu. // Pending (Granskning) stays visible at all times: the badge carries the count. type GroupKey = 'top' | 'arbeta' | 'analys' | 'data' | 'skatt' // Folds: collapsible sub-lists inside a group (concept: Register under DATA, // Bokslut under SKATT & BOKSLUT). Child rows render text-indented behind a // hairline; open/closed state persists in user_preferences.ui_state. type FoldKey = 'register' | 'bokslut' interface NavItem { href: string labelKey: NavLabelKey icon: typeof LayoutDashboard group: GroupKey // When set, the item renders inside this fold (consecutive items with the // same fold key form one fold block at that position in the group). fold?: FoldKey // Payroll surfaces: visible only to employers: every aktiebolag (unchanged // behaviour) plus any company that has registered as an employer via // company_settings.pays_salaries (e.g. an enskild firma with staff). #782 employerOnly?: boolean // Dimension surfaces: visible only when the company has opted in via // company_settings.dimensions_enabled (UI-visibility gate only; the pages // and APIs work regardless, dimensions plan §2). requiresDimensions?: boolean // Kundorder surfaces: visible only when the company has opted in via // company_settings.sales_orders_enabled (UI-visibility gate only; the // pages and APIs work regardless). requiresSalesOrders?: boolean // Webshop surfaces: visible only when the company has an active // WooCommerce/Shopify connection or already-imported order rows. // UI-visibility gate only; the page and APIs work regardless. requiresWebshop?: boolean // Körjournal surfaces: visible only when the company has opted in via the // bookkeeping settings toggle (company_settings.mileage_enabled) or already // has trips. UI-visibility gate only; the page and APIs work regardless. requiresMileage?: boolean // Paywall surfaces: hidden unless the active company holds this paid // capability. Cosmetic only, the page and API gates are the real // enforcement; this just keeps the sidebar honest for non-payers. requiredCapability?: CapabilityKey // Statutory surfaces that only exist for one company form (INK2 vs // NE-bilaga, årsredovisning): hidden for the other entity type. entityOnly?: EntityType // Byrå cockpit surfaces (WL-14): visible only to byrå team members // (teams.kind = 'byra'). The /clients page + API enforce server-side. byraOnly?: boolean hidden?: boolean comingSoon?: boolean devBadge?: boolean betaBadge?: boolean } // Nav layout per the UI-migration concept (ui_migration_plan.md PR 2): // same destinations, concept ordering, with the Register and Bokslut // sub-lists as folds. const navItems: NavItem[] = [ // Top section: flat list, always visible, no header. (Flöden joins here // when the flow engine exists.) { href: '/', labelKey: 'home', icon: Home, group: 'top' }, // Byrå cockpit (WL-14): the client list, byrå team members only. { href: '/clients', labelKey: 'clients', icon: Briefcase, group: 'top', byraOnly: true }, { href: '/chat', labelKey: 'assistant', icon: Sparkles, group: 'top' }, // Arbeta: everything the user produces, bookkeeping funnel first // (Bokföring · Underlag · Transaktioner · Granskning), then the // transactional flows. employerOnly: aktiebolag or pays_salaries. #782 { href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'arbeta' }, { href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'arbeta', requiredCapability: EXTENSION_REQUIRED_CAPABILITY['general/invoice-inbox'] }, { href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'arbeta' }, { href: '/reconciliation', labelKey: 'reconciliation', icon: Scale, group: 'arbeta' }, { href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' }, // Kundorder: opt-in via the bookkeeping settings toggle (UI gate only). { href: '/sales-orders', labelKey: 'sales_orders', icon: ClipboardList, group: 'arbeta', requiresSalesOrders: true }, { href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' }, // Webshop orders: visible only for companies that actually have a webshop // hooked up (active WooCommerce/Shopify connection or existing order rows). // Deliberately NOT capability-gated: a company whose entitlement lapsed // must still reach its already-imported orders (accounting underlag). { href: '/orders', labelKey: 'webshop_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true, betaBadge: true }, { href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' }, { href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true }, // Körjournal: hidden by default (most companies have no car); shows when // the settings toggle is on or trips already exist (hybrid gate, same // "data stays reachable" reasoning as the Order row above). { href: '/mileage', labelKey: 'mileage', icon: Car, group: 'arbeta', requiresMileage: true }, // Analys: read the numbers. { href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' }, { href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' }, // Data: the Register fold (master data) + Importera/exportera as its own // row. Anställda is a register (you edit an employee rarely, you run // payroll monthly), so it lives here while Löner stays in Arbeta. { href: '/customers', labelKey: 'customers', icon: Users, group: 'data', fold: 'register' }, { href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'data', fold: 'register' }, { href: '/articles', labelKey: 'articles', icon: Tag, group: 'data', fold: 'register' }, { href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'data', fold: 'register', employerOnly: true }, { href: '/assets', labelKey: 'assets', icon: Package, group: 'data', fold: 'register' }, { href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'data', fold: 'register' }, { href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'data', fold: 'register', requiresDimensions: true }, { href: '/import', labelKey: 'import', icon: Upload, group: 'data' }, // Skatt & bokslut: everything submitted to the state; the year-end chain // (periodiseringar → årsbokslut → årsredovisning → inkomstdeklaration) // lives in the Bokslut fold in workflow order; the last two are // entity-gated because the surface only exists for one company form. { href: '/reports/vat-declaration', labelKey: 'vat_declaration', icon: Percent, group: 'skatt' }, { href: '/skattekonto', labelKey: 'skattekonto', icon: Landmark, group: 'skatt' }, { href: '/deadlines', labelKey: 'deadlines', icon: CalendarClock, group: 'skatt' }, { href: '/bookkeeping/periodiseringar', labelKey: 'periodiseringar', icon: CalendarRange, group: 'skatt', fold: 'bokslut' }, { href: '/bookkeeping/year-end', labelKey: 'year_end', icon: FileCheck, group: 'skatt', fold: 'bokslut' }, { href: '/reports/bokslutsbilagor', labelKey: 'bokslutsbilagor', icon: FolderArchive, group: 'skatt', fold: 'bokslut' }, { href: '/bookkeeping/year-end/arsredovisning', labelKey: 'annual_report', icon: ScrollText, group: 'skatt', fold: 'bokslut', entityOnly: 'aktiebolag' }, { href: '/reports/ink2-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', fold: 'bokslut', entityOnly: 'aktiebolag' }, { href: '/reports/ne-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', fold: 'bokslut', entityOnly: 'enskild_firma' }, ] // Byrå cockpit sidebar (lean mode): while a byrå team member is on a // cockpit route (client overview + the /byra pages) the sidebar shows only // these four byrå-scope entries instead of the full company nav. Entering a // company (performCompanySwitch from the client list) is a hard navigation // to a company route, which swaps the full list back in. const cockpitNavItems: NavItem[] = [ { href: '/byra', labelKey: 'home', icon: Home, group: 'top' }, { href: '/clients', labelKey: 'clients', icon: Briefcase, group: 'top' }, { href: '/byra/automations', labelKey: 'automations', icon: Workflow, group: 'top' }, { href: '/byra/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'top' }, ] // Cockpit routes are byrå-scope, not company-scope: they decide which // sidebar variant renders and stay enabled without an active company. const COCKPIT_PATHS = ['/byra', '/clients'] const isCockpitPath = (pathname: string) => COCKPIT_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)) // Fold header presentation (label + icon). The fold rows themselves come // from navItems entries carrying the matching `fold` key. const foldConfig: Record = { register: { labelKey: 'fold_register', icon: Library }, bokslut: { labelKey: 'fold_bokslut', icon: BookCheck }, } // Splits a group's items into a render sequence of plain items and fold // blocks (consecutive same-fold items become one block). type NavSegment = | { type: 'item'; item: NavItem } | { type: 'fold'; key: FoldKey; items: NavItem[] } function segmentItems(items: NavItem[]): NavSegment[] { const segments: NavSegment[] = [] for (const item of items) { if (item.fold) { const last = segments[segments.length - 1] if (last && last.type === 'fold' && last.key === item.fold) { last.items.push(item) } else { segments.push({ type: 'fold', key: item.fold, items: [item] }) } } else { segments.push({ type: 'item', item }) } } return segments } // Map known extension hrefs to nav translation keys so sidebar labels translate. // Extensions whose manifest label happens to be English-ready can stay null. function extensionLabelKey(href: string): string | null { if (href === '/e/general/tic') return 'ext_tic' if (href === '/e/general/invoice-inbox') return 'ext_invoice_inbox' return null } const groupLabelKey: Record, string> = { arbeta: 'group_work', analys: 'group_analysis', data: 'group_data', skatt: 'group_tax', } export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, salesOrdersEnabled = false, hasWebshop = false, hasMileage = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) { const pathname = usePathname() const router = useRouter() const supabase = useRealtimeSupabase() const { company, capabilities, byraTeam } = useCompany() // Agent identity drives the "Assistent" nav icon: when the user has // built their assistant we show its chosen avatar instead of the // generic Sparkles glyph. const { identity: agentIdentity } = useAgentSheet() const tNav = useTranslations('nav') const tCommon = useTranslations('common') const tSwitcher = useTranslations('company_switcher') const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isClosing, setIsClosing] = useState(false) const closeTimerRef = useRef | null>(null) // Badge counts load client-side after mount (and revalidate via the // realtime subscriptions below). They used to arrive as server props, which // put two head-count queries on the critical path of every dashboard // navigation for numbers nobody needs before first paint. const { uncategorized: uncategorizedCount, pendingOperations: pendingOpsCount, refresh: refreshBadges, } = useWorklistBadges(company?.id) const hasCompany = !!company // Byrå cockpit mode: lean sidebar on cockpit routes, full sidebar (with a // back-to-clients link) once the member is inside a company. Settings opens // as a modal OVER the current surface (intercepted route), so while the // pathname is /settings/* the sidebar keeps the mode of the page underneath // instead of flipping to the company nav behind the modal. const onSettings = pathname.startsWith('/settings') const onCockpitPath = isCockpitPath(pathname) // Settings opened from the cockpit carry ?ctx=byra (user menu / mobile // nav links, preserved by SettingsRail). It is the signal that SURVIVES a // hard refresh, when the previous-surface memory below starts over at // false and the sidebar used to flip to the full company nav. const ctxByra = useSearchParams().get('ctx') === 'byra' // Previous-render memory via the adjust-state-during-render pattern // (react.dev: storing information from previous renders); a ref would be // simpler but refs must not be read or written during render. const [lastNonSettingsCockpit, setLastNonSettingsCockpit] = useState(false) if (!onSettings && onCockpitPath !== lastNonSettingsCockpit) { setLastNonSettingsCockpit(onCockpitPath) } const cockpitMode = !!byraTeam && (onSettings ? lastNonSettingsCockpit || ctxByra : onCockpitPath) // Where "Tillbaka till klienter" points (home-domain rule, WL-01): absolute // URL when the byrå cockpit is homed on another host (resolveCockpitHref in // the layout), relative '/clients' otherwise. The layout's pre-brand // no-company branch leaves cockpitHref unset; there the relative fallback // reproduces the pre-cockpitHref link exactly on the cockpit/settings // surfaces that branch can render. Cross-host hops render a plain // (no client-router prefetch across origins) and carry a "Hanteras via" // hint, since the other host will ask for a login (per-host sessions). const cockpitHref = byraTeam?.cockpitHref ?? '/clients' const cockpitExternal = cockpitHref.startsWith('https://') const cockpitHint = cockpitExternal ? tSwitcher('managed_via', { domain: new URL(cockpitHref).hostname }) : null // Byrå-scope surfaces, not company surfaces: they must stay reachable even // when the active company is unresolved. const ALWAYS_ENABLED = new Set(['/settings', '/clients', '/byra', '/byra/automations', '/byra/kpi']) const isItemEnabled = (href: string) => { // The back-to-clients link may be absolute (cross-host cockpit); judge // it by its path so it stays as reachable as the relative form. const path = href.startsWith('https://') ? new URL(href).pathname : href const base = path.split('?')[0] return hasCompany || ALWAYS_ENABLED.has(base) || base.startsWith('/settings') } type ExpandableGroup = Exclude // Sidebar collapse (64px icon rail). The width is CSS-variable-driven: // #dash-shell sets --nav-w inline (server-rendered from ui_state), and // both the aside and
read it, so one property flip resizes the // whole shell in lockstep. The React state only drives which sidebar // variant renders. const [collapsed, setCollapsed] = useState(initialUiState?.nav_collapsed === true) const toggleCollapsed = () => { const next = !collapsed setCollapsed(next) document .getElementById('dash-shell') ?.style.setProperty('--nav-w', next ? '64px' : '248px') persistUiState({ nav_collapsed: next }) } // Fold state (Register, Bokslut). Closed by default; an active child // route forces its fold open so deep links never land in a hidden row. const [foldsOpen, setFoldsOpen] = useState>({ register: initialUiState?.nav_folds?.register ?? false, bokslut: initialUiState?.nav_folds?.bokslut ?? false, }) const toggleFold = (key: FoldKey) => { setFoldsOpen((prev) => { const next = { ...prev, [key]: !prev[key] } persistUiState({ nav_folds: { [key]: next[key] } }) return next }) } const isFoldOpen = (key: FoldKey, items: NavItem[]) => foldsOpen[key] || items.some((it) => isActive(it.href)) const openMobileMenu = () => { if (closeTimerRef.current) { clearTimeout(closeTimerRef.current) closeTimerRef.current = null } setIsClosing(false) setIsMobileMenuOpen(true) } const handleLogout = async () => { resetAnalyticsIdentity() await supabase.auth.signOut() router.push(isSandbox ? '/sandbox' : '/login') } const isActive = (href: string) => { if (href === '/') { return pathname === '/' } // Cockpit Hem: exact match so /byra/automations and /byra/kpi light up // their own rows only. if (href === '/byra') { return pathname === '/byra' } if (href === '/salary') { return pathname === '/salary' || pathname.startsWith('/salary/runs') } // Routes with their own rows under Skatt & bokslut (Bokslut, Moms, // Periodiseringar, Årsredovisning, Inkomstdeklaration) are carved out // of their parent routes so exactly one row lights up. if (href === '/bookkeeping') { return ( pathname.startsWith('/bookkeeping') && !pathname.startsWith('/bookkeeping/year-end') && !pathname.startsWith('/bookkeeping/periodiseringar') ) } if (href === '/bookkeeping/year-end') { return ( pathname.startsWith('/bookkeeping/year-end') && !pathname.startsWith('/bookkeeping/year-end/arsredovisning') ) } if (href === '/reports') { return ( pathname.startsWith('/reports') && !pathname.startsWith('/reports/vat-declaration') && !pathname.startsWith('/reports/ink2-declaration') && !pathname.startsWith('/reports/ne-declaration') ) } return pathname.startsWith(href) } const closeMobileMenu = () => { setIsClosing(true) closeTimerRef.current = setTimeout(() => { setIsMobileMenuOpen(false) setIsClosing(false) closeTimerRef.current = null }, 200) } useEffect(() => { if (!company?.id) return // Realtime keeps the badges live; a trailing debounce collapses event // bursts (bulk booking / bulk approvals emit one event per row) into a // single SWR revalidation instead of a request stampede. let debounce: ReturnType | null = null const queueRefresh = () => { if (debounce) clearTimeout(debounce) debounce = setTimeout(() => void refreshBadges(), 400) } const channel = supabase .channel(`dashboard-nav:badges:${company.id}`) .on( 'postgres_changes', { event: '*', schema: 'public', table: 'transactions', filter: `company_id=eq.${company.id}`, }, queueRefresh, ) .on( 'postgres_changes', { event: '*', schema: 'public', table: 'pending_operations', filter: `company_id=eq.${company.id}`, }, queueRefresh, ) .subscribe() return () => { if (debounce) clearTimeout(debounce) void supabase.removeChannel(channel) } }, [company?.id, supabase, refreshBadges]) const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs) // Render a nav item's leading glyph. The "Assistent" entry (/chat) shows // the agent's chosen avatar once built; everything else (and the // pre-onboarding /chat) uses its lucide icon. The passed className carries // size + margin + active color; tailwind-merge lets the explicit h/w win // over AgentAvatar's default box size. const renderNavIcon = ( item: { href: string; icon: typeof LayoutDashboard }, className: string, ) => { if (item.href === '/chat' && agentIdentity.avatarId) { return ( ) } const Icon = item.icon return } const isEmployer = entityType === 'aktiebolag' || paysSalaries const filteredItems = (cockpitMode ? cockpitNavItems : navItems).filter(item => { if (item.hidden) return false if (hiddenNavHrefs.has(item.href)) return false // Payroll (employerOnly) is hidden until the company is an employer, an // aktiebolag, or any entity that has flagged pays_salaries. #782 if (item.employerOnly && !isEmployer) return false // Dimension surfaces are hidden until the company opts in via the // bookkeeping settings toggle (company_settings.dimensions_enabled). if (item.requiresDimensions && !dimensionsEnabled) return false if (item.requiresSalesOrders && !salesOrdersEnabled) return false // Webshop surfaces are hidden until a store is connected (or order rows // already exist from a since-disconnected store). if (item.requiresWebshop && !hasWebshop) return false // Körjournal is hidden until the company opts in via the bookkeeping // settings toggle (or trips already exist, e.g. created via MCP). if (item.requiresMileage && !hasMileage) return false // Paywalled surfaces (e.g. the AI-only Dokumentinkorg) are hidden unless // the active company holds the capability. The page + API gates enforce // the paywall; this keeps the sidebar from advertising a dead workspace. if (item.requiredCapability && !capabilities.includes(item.requiredCapability)) return false // Entity-gated statutory surfaces: INK2/ÅR for aktiebolag, NE for // enskild firma; the page for the other form doesn't exist. if (item.entityOnly && item.entityOnly !== entityType) return false // Byrå cockpit: the Klienter entry lives in the lean cockpit sidebar // (cockpitNavItems); in company mode the pinned back-to-clients link // replaces it, and non-byrå users never see it (WL-14). if (item.byraOnly) return false // Hide the Assistent (/chat) tab until the agent is built: mirrors the // floating AgentTrigger and avoids a nav entry that only bounces to the // home checklist (chat/layout redirects unverified users to /). if (item.href === '/chat' && !agentIdentity.isVerified) return false // Granskning stays in the top nav at all times now: the badge // surfaces the count when there are pending ops, but the link is // always present so users can navigate there manually. return true }) const topItems = filteredItems.filter((i) => i.group === 'top') // The TIC workspace (/e/general/tic, labelled "Företagsprofil") surfaces // the same Bolagsuppgifter now shown under Inställningar → Företagsprofil. // Drop it from the nav so the company profile lives in exactly one place. // Extension workspaces are company surfaces: none in cockpit mode. const visibleExtensionNavItems = cockpitMode ? [] : extensionNavItems.filter((i) => i.href !== '/e/general/tic') const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = cockpitMode ? [] : [ { key: 'arbeta', items: filteredItems.filter((i) => i.group === 'arbeta') }, { key: 'analys', items: filteredItems.filter((i) => i.group === 'analys') }, { key: 'data', items: filteredItems.filter((i) => i.group === 'data') }, { key: 'skatt', items: filteredItems.filter((i) => i.group === 'skatt') }, ] // Flat leaf list for the collapsed 64px icon rail: fold children render // as plain icons (group headers and fold headers disappear). const railItems = [...topItems, ...sidebarGroups.flatMap(({ items }) => items)] const allMobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = cockpitMode ? cockpitNavItems.map(({ href, labelKey, icon }) => ({ href, labelKey, icon })) : [ { href: '/', labelKey: 'home', icon: Home }, { href: '/chat', labelKey: 'assistant', icon: Sparkles }, { href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight }, ] // Same gate as the sidebar: no Assistent tab until the agent is built. const mobileNavItems = allMobileNavItems.filter( (item) => item.href !== '/chat' || agentIdentity.isVerified, ) const renderBadge = (item: NavItem | { comingSoon?: boolean; devBadge?: boolean; betaBadge?: boolean }, position: 'sidebar' | 'mobile') => { const baseClass = position === 'sidebar' ? 'ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5' : 'rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5' if (item.comingSoon) return {tNav('badge_coming_soon')} if (item.devBadge) return {tNav('badge_dev')} if (item.betaBadge) return {tNav('badge_beta')} return null } const badgeFor = (href: string): number | null => href === '/transactions' && uncategorizedCount > 0 ? uncategorizedCount : href === '/pending' && pendingOpsCount > 0 ? pendingOpsCount : null const countBubble = (badge: number) => ( {badge > 99 ? '99+' : badge} ) // One sidebar row (expanded sidebar). Fold children render text-indented // without an icon behind the fold's hairline (concept PR 2). const renderSidebarItem = (item: NavItem, opts?: { child?: boolean }) => { const active = isActive(item.href) const enabled = isItemEnabled(item.href) && !item.comingSoon const badge = badgeFor(item.href) const decorBadge = renderBadge(item, 'sidebar') const content = ( <> {!opts?.child && renderNavIcon( item, cn( 'mr-2.5 h-[15px] w-[15px] flex-shrink-0', active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground', ), )} {tNav(item.labelKey)} {decorBadge ? decorBadge : badge !== null && countBubble(badge)} ) const baseClass = cn( 'group flex items-center px-3 py-[7px] text-[13px] rounded-lg', enabled ? cn( 'transition-colors duration-150', active ? 'bg-secondary text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60', ) : 'text-muted-foreground/40 cursor-not-allowed', ) return enabled ? ( {content} ) : (
{content}
) } // Fold block: header row + grid-rows 0fr/1fr height animation, children // indented behind a hairline left edge. const renderFold = (segKey: FoldKey, items: NavItem[]) => { const open = isFoldOpen(segKey, items) const cfg = foldConfig[segKey] const FoldIcon = cfg.icon return (
{items.map((item) => renderSidebarItem(item, { child: true }))}
) } // Collapsed 64px rail: icon-only rows, native title tooltips, count // bubbles pinned to the icon corner. const renderRailItem = ( item: { href: string labelKey: NavLabelKey icon: typeof LayoutDashboard comingSoon?: boolean /** Cross-origin href: render a plain
, no client-router prefetch. */ external?: boolean /** Extra tooltip context (e.g. "Hanteras via {domain}"). */ hint?: string | null }, ) => { const active = isActive(item.href) const enabled = isItemEnabled(item.href) && !item.comingSoon const badge = badgeFor(item.href) const label = tNav(item.labelKey) const title = item.hint ? `${label} · ${item.hint}` : label const inner = ( {renderNavIcon( item, cn('h-[17px] w-[17px]', active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'), )} {badge !== null && ( {badge > 99 ? '99' : badge} )} ) const baseClass = cn( 'group flex h-9 w-9 items-center justify-center rounded-lg', enabled ? cn('transition-colors duration-150', active ? 'bg-secondary' : 'hover:bg-secondary/60') : 'opacity-40 cursor-not-allowed', ) if (enabled && item.external) { return ( {inner} ) } return enabled ? ( {inner} ) : (
{inner}
) } return ( <> {/* Desktop sidebar */} {/* Width, and the panel margin beside it, share the same 300ms decelerating curve so collapse reads as one movement. */} {/* Mobile bottom navigation. data-mobile-nav is the brand-style hook: on branded hosts the brand style block re-tints the bar's tokens (--card/--border/--primary...) to the deep chrome, mirroring the sidebar; on default hosts the attribute matches nothing. */} {/* Mobile menu: bottom sheet */} {isMobileMenuOpen && ( <> {/* Backdrop */}