'use client' import { useEffect, useState, useRef } from 'react' import Link from 'next/link' import Image from 'next/image' import { usePathname, useRouter } 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, Clock, Sparkles, Percent, Landmark, CalendarClock, CalendarRange, FileCheck, FileSpreadsheet, ScrollText, PanelLeft, PanelLeftClose, Library, BookCheck, } from 'lucide-react' import { getBranding } from '@/lib/branding/service' 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 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 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' | 'customers' | 'articles' | 'supplier_invoices' | 'suppliers' | 'review' | 'transactions' | 'bookkeeping' | 'chart_of_accounts' | 'dimensions' | 'assets' | 'reports' | 'import' | 'salary' | 'employees' | 'vat_declaration' | 'skattekonto' | 'deadlines' | 'periodiseringar' | 'year_end' | 'annual_report' | 'income_declaration' | 'help' | 'settings' // 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 // 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 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' }, { 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: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' }, { href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' }, { href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' }, { href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: 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: '/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' }, ] // 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, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) { const pathname = usePathname() const router = useRouter() const supabase = useRealtimeSupabase() const { company, capabilities, trialEndsAt } = 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 [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) // Trial countdown for the sidebar touchpoint. Computed in an effect (not // during render) so server and client markup agree at hydration; an hourly // tick keeps a long-lived tab from showing yesterday's count. The sync // setState is that hydration strategy, not derived-state-in-effect (the // lint only started analyzing this component once the badge-refresh loop // that made the compiler bail was removed). const [trialDaysLeft, setTrialDaysLeft] = useState(null) useEffect(() => { if (!trialEndsAt) { // eslint-disable-next-line react-hooks/set-state-in-effect setTrialDaysLeft(null) return } const update = () => { const msLeft = new Date(trialEndsAt).getTime() - Date.now() setTrialDaysLeft(msLeft > 0 ? Math.ceil(msLeft / 86_400_000) : null) } update() const id = setInterval(update, 3_600_000) return () => clearInterval(id) }, [trialEndsAt]) const hasCompany = !!company const ALWAYS_ENABLED = new Set(['/settings']) const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href) 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 === '/' } 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 = 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 // 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 // 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. const visibleExtensionNavItems = extensionNavItems.filter( (i) => i.href !== '/e/general/tic', ) const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = [ { 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 }[] = [ { 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 }, ) => { const active = isActive(item.href) const enabled = isItemEnabled(item.href) && !item.comingSoon const badge = badgeFor(item.href) const label = tNav(item.labelKey) 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', ) 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 */} {/* Mobile menu: bottom sheet */} {isMobileMenuOpen && ( <> {/* Backdrop */}