'use client' import { useEffect, useState, useRef } from 'react' import Link from 'next/link' 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, ChevronDown, Building2, Wallet, TrendingUp, ClipboardCheck, HandCoins, Package, Tag, Tags, ChevronRight, ChevronsUpDown, Clock, Sparkles, Percent, Landmark, CalendarClock, CalendarRange, FileCheck, FileSpreadsheet, ScrollText, } 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 { clearRecaptIdentity } from '@/lib/recapt' import { SupportLink } from '@/components/ui/support-link' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu' import CompanySwitcher from '@/components/dashboard/CompanySwitcher' 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 { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys' import type { EntityType } 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 uncategorizedTransactionCount?: number pendingOperationsCount?: number 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 } type NavLabelKey = | 'dashboard' | 'home' | 'assistant' | '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, interaction-mode grouping, dev_docs/nav_ia_redesign.md // Phase 0): same routes as before, regrouped by what the user is doing. // top-of-sidebar : CompanySwitcher (active company / org context). // top section : flat, no dropdown: Hem, Assistent. Hem doubles as // the "what needs me" surface until a dedicated // /inbox exists. // four dropdowns : Arbeta (produce: the bookkeeping funnel first, // then invoices, supplier invoices, payroll), // Analys (KPI + reports), Data (master-data // registers + import/export), Skatt & bokslut // (statutory: VAT, tax account, deadlines, year-end). // bottom-left popover : signed-in user's name + initial, opens upward // to Inställningar, Hjälp, Support, Logga ut. // Help + Settings are NOT in `navItems`; they live in the account popover. // Pending (Granskning) stays visible at all times: the badge carries the count. type GroupKey = 'top' | 'arbeta' | 'analys' | 'data' | 'skatt' interface NavItem { href: string labelKey: NavLabelKey icon: typeof LayoutDashboard group: GroupKey // 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 } const navItems: NavItem[] = [ // Top section: flat list, always visible, no header { href: '/', labelKey: 'home', icon: Home, group: 'top' }, { href: '/chat', labelKey: 'assistant', icon: Sparkles, group: 'top' }, // Arbeta: everything the user produces. The bookkeeping funnel leads // (Bokföring · Underlag · Transaktioner · Granskning: kept as separate // rows until the unified workspace lands), then the transactional flows. // employerOnly: shown to aktiebolag and to any employer (pays_salaries), so an // enskild firma that hires staff gets payroll. Owner self-payroll stays // blocked at the engine/DB layer (EF owner takes egna uttag, not lön). #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. KPI stays a separate row until the fused // Rapporter surface (nav_ia_redesign §F) is built. { href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' }, { href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' }, // Data: master-data registers + data plumbing. Anställda is a register // (you edit an employee rarely, you run payroll monthly), so it lives here // while the Löner flow stays in Arbeta. { href: '/customers', labelKey: 'customers', icon: Users, group: 'data' }, { href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'data' }, { href: '/articles', labelKey: 'articles', icon: Tag, group: 'data' }, { href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'data', employerOnly: true }, { href: '/assets', labelKey: 'assets', icon: Package, group: 'data' }, { href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'data' }, { href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'data', requiresDimensions: true }, { href: '/import', labelKey: 'import', icon: Upload, group: 'data' }, // Skatt & bokslut: everything submitted to the state. Rescues the // previously nav-orphaned /skattekonto and /deadlines, and promotes the // VAT declaration out of the report catalog. The year-end chain // (periodiseringar → bokslut → årsredovisning → inkomstdeklaration) is // listed in workflow order; the last two are entity-gated because the // surfaces only exist for one company form (ÅR + INK2 for aktiebolag, // NE-bilaga for enskild firma). { 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' }, { href: '/bookkeeping/year-end', labelKey: 'year_end', icon: FileCheck, group: 'skatt' }, { href: '/bookkeeping/year-end/arsredovisning', labelKey: 'annual_report', icon: ScrollText, group: 'skatt', entityOnly: 'aktiebolag' }, { href: '/reports/ink2-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', entityOnly: 'aktiebolag' }, { href: '/reports/ne-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', entityOnly: 'enskild_firma' }, ] // 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', } // Best single-character initial we can show in the bottom-left account // trigger. Prefers the first letter of the user's full name; falls back // to the email's first character; falls back to "?" so the avatar never // renders empty. function accountInitial(name: string | null, email: string | null): string { const trimmedName = name?.trim() if (trimmedName && trimmedName.length > 0) return trimmedName[0]!.toUpperCase() const trimmedEmail = email?.trim() if (trimmedEmail && trimmedEmail.length > 0) return trimmedEmail[0]!.toUpperCase() return '?' } export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: 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) const [liveUncategorizedTransactionCount, setLiveUncategorizedTransactionCount] = useState( uncategorizedTransactionCount, ) const refreshInFlightRef = useRef(false) const refreshQueuedRef = useRef(false) // 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. const [trialDaysLeft, setTrialDaysLeft] = useState(null) useEffect(() => { if (!trialEndsAt) { 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) // Per-group collapse state. Default: ALL groups expanded. The user // can see every child link without hunting. Clicking the chevron // collapses the group; opening it again restores the children. // Active route still forces a group expanded even when the user has // manually collapsed it (so deep-linking into /salary doesn't leave // Personal hidden). type ExpandableGroup = Exclude const [manualCollapsed, setManualCollapsed] = useState>({ arbeta: false, analys: false, data: false, skatt: false, }) const toggleGroup = (g: ExpandableGroup) => setManualCollapsed((prev) => ({ ...prev, [g]: !prev[g] })) const openMobileMenu = () => { if (closeTimerRef.current) { clearTimeout(closeTimerRef.current) closeTimerRef.current = null } setIsClosing(false) setIsMobileMenuOpen(true) } const handleLogout = async () => { clearRecaptIdentity() 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 let cancelled = false const refreshUncategorizedCount = async () => { if (!company?.id || cancelled) return if (refreshInFlightRef.current) { refreshQueuedRef.current = true return } refreshInFlightRef.current = true try { do { refreshQueuedRef.current = false const { count, error } = await supabase .from('transactions') .select('id', { count: 'exact', head: true }) .eq('company_id', company.id) .is('is_business', null) .eq('is_ignored', false) if (error) { console.error('Failed to refresh uncategorized transaction count:', error) break } setLiveUncategorizedTransactionCount(count ?? 0) } while (refreshQueuedRef.current && !cancelled) } finally { refreshInFlightRef.current = false refreshQueuedRef.current = false } } void refreshUncategorizedCount() const channel = supabase .channel(`dashboard-nav:transactions:${company.id}`) .on( 'postgres_changes', { event: '*', schema: 'public', table: 'transactions', filter: `company_id=eq.${company.id}`, }, () => { void refreshUncategorizedCount() }, ) .subscribe() return () => { cancelled = true void supabase.removeChannel(channel) } }, [company?.id, supabase]) 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') }, ] // A group is expanded when the user hasn't manually collapsed it OR // an active route lives inside it (the active route always wins so a // deep-link to /salary keeps Personal open even if previously collapsed). const isGroupExpanded = (g: ExpandableGroup, items: NavItem[]) => !manualCollapsed[g] || items.some((it) => isActive(it.href)) 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 } return ( <> {/* Desktop sidebar */} {/* Mobile bottom navigation */} {/* Mobile menu: bottom sheet */} {isMobileMenuOpen && ( <> {/* Backdrop */}