'use client' import { useState, useRef } from 'react' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { LayoutDashboard, Home, Receipt, Users, ArrowLeftRight, BookOpen, BarChart3, Settings, LogOut, Upload, Inbox, Menu, X, HelpCircle, ChevronDown, Building2, Wallet, TrendingUp, ClipboardCheck, HandCoins, Package, Tag, ChevronsUpDown, Sparkles, } 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 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 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' | 'assets' | 'reports' | 'import' | 'salary' | 'employees' | 'help' | 'settings' // New nav layout (May 2026): // top-of-sidebar — CompanySwitcher (active company / org context). // top section — flat, no dropdown: Hem (agent), Underlag, // Transaktioner, Granskning. // four dropdowns — Försäljning, Inköp, Redovisning, Personal. // 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` anymore; they live in the account // popover. KPI moved from main to redovisning. Pending stays visible at all // times — the inline badge carries the count. type GroupKey = 'top' | 'försäljning' | 'inköp' | 'redovisning' | 'personal' 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 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' }, { href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'top' }, { href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'top' }, { href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'top' }, // Försäljning dropdown { href: '/invoices', labelKey: 'invoices', icon: Receipt, group: 'försäljning' }, { href: '/customers', labelKey: 'customers', icon: Users, group: 'försäljning' }, { href: '/articles', labelKey: 'articles', icon: Tag, group: 'försäljning' }, // Inköp dropdown { href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'inköp' }, { href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'inköp' }, // Redovisning dropdown { href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'redovisning' }, { href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' }, { href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' }, { href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' }, { href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' }, // Personal — "Beta" badge while we validate the end-to-end salary + AGI flow. // 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: '/salary', labelKey: 'salary', icon: HandCoins, group: 'personal', employerOnly: true, betaBadge: true }, { href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'personal', employerOnly: true, betaBadge: true }, ] // 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> = { försäljning: 'group_sales', inköp: 'group_purchases', redovisning: 'group_accounting', personal: 'group_personnel', } // 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, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) { const pathname = usePathname() const router = useRouter() const supabase = createClient() const { company } = 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 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>({ försäljning: false, inköp: false, redovisning: false, personal: 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') } return pathname.startsWith(href) } const closeMobileMenu = () => { setIsClosing(true) closeTimerRef.current = setTimeout(() => { setIsMobileMenuOpen(false) setIsClosing(false) closeTimerRef.current = null }, 200) } 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 // 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: 'försäljning', items: filteredItems.filter((i) => i.group === 'försäljning') }, { key: 'inköp', items: filteredItems.filter((i) => i.group === 'inköp') }, { key: 'redovisning', items: filteredItems.filter((i) => i.group === 'redovisning') }, { key: 'personal', items: filteredItems.filter((i) => i.group === 'personal') }, ] // 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 */}