* feat(ui): frame-layout shell, pill buttons, 24px page titles (UI migration PR 1) The visual shell from the concept, zero behavior change: - New --frame token pair (40 18% 96% light / 0 0% 5% dark); the dashboard wrapper is bg-frame and <main> becomes a rounded 12px panel with its own inner scroll (md-gated; mobile keeps document flow + bottom nav) - Sidebar goes borderless/transparent on the frame - Buttons are pills app-wide (radius 99px, default 7px/16px padding, 13px text, icon buttons become circles), set once in components/ui/button.tsx - PageHeader locked at exactly 24px/32px Hedvig serif - MainContainer resets panel scroll on route change (Next's window scroll-to-top never fires for an inner scroll container) - chat layout + extension workspaces switch viewport-height formulas to h-full so they fill the panel instead of overflowing it by 20px - .claude/rules/design.md rewritten with the 14 locked UI-migration conventions from dev_docs/ui_migration_plan.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): clamp hand-rolled page titles to the locked 24px/32px Transaktioner (TransactionStatusBar) and 13 other pages hand-roll their h1 instead of using PageHeader, so they kept text-3xl/4xl after the shell change. Clamp them all to font-display text-2xl leading-8. Onboarding heroes and headline numbers are intentionally untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): keep the frame strip above the panel on banner-less accounts <main>'s 10px top margin was the first in-flow margin inside the shell wrapper, so it collapsed through the wrapper and pushed the whole shell down, showing white body background above the panel instead of the warm frame strip (only visible on real accounts: the sandbox banner blocked the collapse). Flex containers never collapse child margins, so the shell wrappers become md:flex md:flex-col. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1139 lines
50 KiB
TypeScript
1139 lines
50 KiB
TypeScript
'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 { useWorklistBadges } from '@/lib/hooks/use-worklist-badges'
|
|
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
|
|
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'
|
|
| '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, 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' },
|
|
// "Vad din agent vet" now lives inside Settings (Assistenten -> Kunskap,
|
|
// the default view), reachable via /settings/assistant, not the top nav.
|
|
// 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<Exclude<GroupKey, 'top'>, 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, 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<ReturnType<typeof setTimeout> | 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<number | null>(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)
|
|
// 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<GroupKey, 'top'>
|
|
const [manualCollapsed, setManualCollapsed] = useState<Record<ExpandableGroup, boolean>>({
|
|
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
|
|
|
|
// 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<typeof setTimeout> | 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 (
|
|
<AgentAvatar
|
|
avatarId={agentIdentity.avatarId}
|
|
size="xs"
|
|
alt={agentIdentity.displayName ?? 'Assistent'}
|
|
className={className}
|
|
/>
|
|
)
|
|
}
|
|
const Icon = item.icon
|
|
return <Icon className={className} />
|
|
}
|
|
|
|
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 <span className={baseClass}>{tNav('badge_coming_soon')}</span>
|
|
if (item.devBadge) return <span className={baseClass}>{tNav('badge_dev')}</span>
|
|
if (item.betaBadge) return <span className={baseClass}>{tNav('badge_beta')}</span>
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Desktop sidebar */}
|
|
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-64 md:flex-col">
|
|
{/* Borderless on the frame: the panel next to it carries the border */}
|
|
<div className="flex min-h-0 flex-1 flex-col bg-transparent">
|
|
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
|
|
{/* Company switcher pinned to the top: the active company is
|
|
the strongest piece of context for everything below it. */}
|
|
<div className="px-5 mb-8">
|
|
<CompanySwitcher />
|
|
</div>
|
|
<nav className="px-3" aria-label={tNav('main_navigation')}>
|
|
{/* Top section: flat, no header. Hem, Assistent. */}
|
|
<div className="mb-4 space-y-px">
|
|
{topItems.map((item) => {
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href)
|
|
const badge: number | null = null
|
|
const decorBadge = renderBadge(item, 'sidebar')
|
|
const content = (
|
|
<>
|
|
{renderNavIcon(
|
|
item,
|
|
cn(
|
|
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
|
|
active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground',
|
|
),
|
|
)}
|
|
<span className="flex-1">{tNav(item.labelKey)}</span>
|
|
{decorBadge ? decorBadge : badge !== null && (
|
|
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
)}
|
|
</>
|
|
)
|
|
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 ? (
|
|
<Link key={item.href} href={item.href} className={baseClass}>
|
|
{content}
|
|
</Link>
|
|
) : (
|
|
<div
|
|
key={item.href}
|
|
className={baseClass}
|
|
aria-disabled="true"
|
|
title={tNav('needs_company_tooltip')}
|
|
>
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Collapsible groups: Arbeta, Analys, Data, Skatt & bokslut */}
|
|
{sidebarGroups
|
|
.filter(({ items }) => items.length > 0)
|
|
.map(({ key, items }) => {
|
|
const expanded = isGroupExpanded(key, items)
|
|
return (
|
|
<div key={key} className="mb-1">
|
|
<button
|
|
onClick={() => toggleGroup(key)}
|
|
className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] hover:text-foreground transition-colors rounded-lg"
|
|
>
|
|
<span>{tNav(groupLabelKey[key])}</span>
|
|
<ChevronDown
|
|
className={cn(
|
|
'h-3 w-3 transition-transform duration-200',
|
|
expanded && 'rotate-180',
|
|
)}
|
|
/>
|
|
</button>
|
|
{expanded && (
|
|
<div className="space-y-px animate-fade-in mb-2">
|
|
{items.map((item) => {
|
|
const Icon = item.icon
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href) && !item.comingSoon
|
|
const badge =
|
|
item.href === '/transactions' && uncategorizedCount > 0
|
|
? uncategorizedCount
|
|
: item.href === '/pending' && pendingOpsCount > 0
|
|
? pendingOpsCount
|
|
: null
|
|
const decorBadge = renderBadge(item, 'sidebar')
|
|
const content = (
|
|
<>
|
|
<Icon
|
|
className={cn(
|
|
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
|
|
active
|
|
? 'text-primary'
|
|
: 'text-muted-foreground group-hover:text-foreground',
|
|
)}
|
|
/>
|
|
<span className="flex-1">{tNav(item.labelKey)}</span>
|
|
{decorBadge ? decorBadge : badge !== null && (
|
|
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
)}
|
|
</>
|
|
)
|
|
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 ? (
|
|
<Link key={item.href} href={item.href} className={baseClass}>
|
|
{content}
|
|
</Link>
|
|
) : (
|
|
<div
|
|
key={item.href}
|
|
className={baseClass}
|
|
aria-disabled="true"
|
|
title={
|
|
item.comingSoon
|
|
? tNav('badge_coming_soon')
|
|
: tNav('needs_company_tooltip')
|
|
}
|
|
>
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
{/* Extension nav items land in Arbeta since extension
|
|
workspaces are work surfaces. Future categorised
|
|
extensions can opt into a different group via
|
|
their manifest. */}
|
|
{key === 'arbeta' &&
|
|
visibleExtensionNavItems.map((item) => {
|
|
const Icon = resolveIcon(item.icon)
|
|
const active = isActive(item.href)
|
|
const enabled = hasCompany
|
|
const labelTranslationKey = extensionLabelKey(item.href)
|
|
const label = labelTranslationKey
|
|
? tNav(labelTranslationKey)
|
|
: item.label
|
|
const content = (
|
|
<>
|
|
<Icon
|
|
className={cn(
|
|
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
|
|
active
|
|
? 'text-primary'
|
|
: 'text-muted-foreground group-hover:text-foreground',
|
|
)}
|
|
/>
|
|
{label}
|
|
</>
|
|
)
|
|
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 ? (
|
|
<Link key={item.href} href={item.href} className={baseClass}>
|
|
{content}
|
|
</Link>
|
|
) : (
|
|
<div
|
|
key={item.href}
|
|
className={baseClass}
|
|
aria-disabled="true"
|
|
title={tNav('needs_company_tooltip')}
|
|
>
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Trial countdown touchpoint: the paywall is a lifecycle flow, not
|
|
a settings page, so trial state stays quietly visible in the
|
|
chrome instead of only inside Inställningar → Abonnemang.
|
|
Hidden for sandbox/demo (no checkout) and once any non-trial
|
|
grant is active (trialEndsAt is null then). */}
|
|
{!isSandbox && trialDaysLeft !== null && (
|
|
<div className="flex-shrink-0 px-3 pb-2">
|
|
<Link
|
|
href="/settings/billing"
|
|
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors duration-150"
|
|
>
|
|
<Clock className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="flex-1 truncate">
|
|
{tNav('trial_days_left', { days: trialDaysLeft })}
|
|
</span>
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Account popover (bottom-left). Triggered by the signed-in
|
|
user's name + initial. Holds Inställningar, Hjälp, Support,
|
|
Logga ut. CompanySwitcher lives at the top of the sidebar,
|
|
not in here, different concept ("which company am I working
|
|
with" vs "who am I logged in as"). */}
|
|
<div className="flex-shrink-0 px-3 py-3 border-t border-border">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors duration-150"
|
|
>
|
|
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-secondary text-[11px] font-semibold uppercase text-foreground">
|
|
{accountInitial(userName, userEmail)}
|
|
</span>
|
|
<span className="flex-1 truncate font-medium text-foreground">
|
|
{userName?.trim() || userEmail || tNav('mitt_konto')}
|
|
</span>
|
|
<ChevronsUpDown className="h-3.5 w-3.5 opacity-50 group-hover:opacity-100" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="top" align="start" className="w-60">
|
|
{(userName || userEmail) && (
|
|
<>
|
|
<DropdownMenuLabel className="font-normal">
|
|
<div className="flex flex-col gap-0.5">
|
|
{userName && (
|
|
<span className="text-sm font-medium text-foreground truncate">
|
|
{userName}
|
|
</span>
|
|
)}
|
|
{userEmail && (
|
|
<span className="text-xs text-muted-foreground truncate">
|
|
{userEmail}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
<DropdownMenuItem asChild>
|
|
<Link href="/settings" className="cursor-pointer">
|
|
<Settings className="mr-2 h-4 w-4" />
|
|
{tNav('settings')}
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild>
|
|
<Link href="/help" className="cursor-pointer">
|
|
<HelpCircle className="mr-2 h-4 w-4" />
|
|
{tNav('help')}
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild>
|
|
<SupportLink variant="muted" className="cursor-pointer" />
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onSelect={(e) => {
|
|
e.preventDefault()
|
|
void handleLogout()
|
|
}}
|
|
className="cursor-pointer text-muted-foreground focus:text-foreground"
|
|
>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Mobile bottom navigation */}
|
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
|
|
<div className="flex items-center justify-around h-16 px-2">
|
|
{mobileNavItems.map((item) => {
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href)
|
|
const badge = item.href === '/transactions' && uncategorizedCount > 0
|
|
? uncategorizedCount
|
|
: null
|
|
|
|
const content = (
|
|
<>
|
|
<div className="relative">
|
|
{renderNavIcon(item, cn('h-5 w-5 mb-1', active && 'text-primary'))}
|
|
{badge !== null && (
|
|
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className={cn(
|
|
"truncate",
|
|
active && "font-medium"
|
|
)}>{tNav(item.labelKey)}</span>
|
|
</>
|
|
)
|
|
const baseClass = cn(
|
|
'relative flex flex-col items-center justify-center flex-1 h-full text-xs',
|
|
enabled
|
|
? cn(
|
|
'transition-colors duration-200',
|
|
active ? 'text-primary' : 'text-muted-foreground'
|
|
)
|
|
: 'text-muted-foreground/40'
|
|
)
|
|
|
|
return enabled ? (
|
|
<Link key={item.href} href={item.href} className={baseClass}>
|
|
{content}
|
|
</Link>
|
|
) : (
|
|
<div key={item.href} className={baseClass} aria-disabled="true">
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
{/* Menu button */}
|
|
<button
|
|
onClick={openMobileMenu}
|
|
aria-label={tNav('open_menu')}
|
|
className="flex flex-col items-center justify-center flex-1 h-full text-xs text-muted-foreground transition-colors duration-200"
|
|
>
|
|
<Menu className="h-5 w-5 mb-1" />
|
|
<span>{tNav('menu')}</span>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Mobile menu: bottom sheet */}
|
|
{isMobileMenuOpen && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
className={cn(
|
|
"md:hidden fixed inset-0 bg-background/80 backdrop-blur-sm z-50",
|
|
isClosing ? "animate-out fade-out duration-200" : "animate-in fade-in duration-300"
|
|
)}
|
|
onClick={closeMobileMenu}
|
|
aria-hidden="true"
|
|
/>
|
|
{/* Bottom sheet */}
|
|
<div
|
|
className={cn(
|
|
"md:hidden fixed inset-x-0 bottom-0 z-50 bg-card rounded-t-2xl border-t border-border/40 overflow-y-auto overscroll-contain",
|
|
isClosing
|
|
? "animate-out slide-out-to-bottom duration-200"
|
|
: "animate-in slide-in-from-bottom duration-300"
|
|
)}
|
|
style={{ maxHeight: '85dvh', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
|
role="dialog"
|
|
aria-label={tNav('navigation_menu')}
|
|
>
|
|
{/* Drag handle */}
|
|
<div className="flex justify-center pt-3 pb-1 sticky top-0 bg-card rounded-t-2xl">
|
|
<div className="w-8 h-1 rounded-full bg-muted-foreground/25" />
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="px-4 pb-2 flex items-center justify-between">
|
|
<div className="flex-1 min-w-0 mr-2">
|
|
<CompanySwitcher />
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 -mr-1"
|
|
onClick={closeMobileMenu}
|
|
aria-label={tNav('close_menu')}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<div className="px-2">
|
|
{/* Top items (Hem, Assistent) */}
|
|
<div className="space-y-0.5">
|
|
{topItems.map((item) => {
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href)
|
|
const badge: number | null = null
|
|
const decorBadge = renderBadge(item, 'mobile')
|
|
const content = (
|
|
<>
|
|
{renderNavIcon(item, cn('h-[18px] w-[18px] flex-shrink-0', active ? 'text-primary' : 'text-muted-foreground'))}
|
|
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
|
|
{decorBadge ? decorBadge : badge !== null && (
|
|
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
)}
|
|
</>
|
|
)
|
|
const baseClass = cn(
|
|
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
|
|
enabled
|
|
? cn(
|
|
'transition-colors',
|
|
active
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
: 'text-foreground active:bg-muted/60'
|
|
)
|
|
: 'text-muted-foreground/40'
|
|
)
|
|
return enabled ? (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={closeMobileMenu}
|
|
className={baseClass}
|
|
>
|
|
{content}
|
|
</Link>
|
|
) : (
|
|
<div key={item.href} className={baseClass} aria-disabled="true">
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Arbeta / Analys / Data / Skatt & bokslut groups (mobile) */}
|
|
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items }) => (
|
|
<div key={key}>
|
|
<div className="flex items-center gap-3 my-1.5 px-3">
|
|
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav(groupLabelKey[key])}</span>
|
|
<div className="flex-1 h-px bg-border/30" />
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{items.map((item) => {
|
|
const Icon = item.icon
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href) && !item.comingSoon
|
|
const badge = item.href === '/transactions' && uncategorizedCount > 0
|
|
? uncategorizedCount
|
|
: item.href === '/pending' && pendingOpsCount > 0
|
|
? pendingOpsCount
|
|
: null
|
|
const decorBadge = renderBadge(item, 'mobile')
|
|
const content = (
|
|
<>
|
|
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
|
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
|
|
{decorBadge ? decorBadge : badge !== null && (
|
|
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
)}
|
|
</>
|
|
)
|
|
const baseClass = cn(
|
|
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
|
|
enabled
|
|
? cn(
|
|
'transition-colors',
|
|
active
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
: 'text-foreground active:bg-muted/60'
|
|
)
|
|
: 'text-muted-foreground/40'
|
|
)
|
|
return enabled ? (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={closeMobileMenu}
|
|
className={baseClass}
|
|
>
|
|
{content}
|
|
</Link>
|
|
) : (
|
|
<div key={item.href} className={baseClass} aria-disabled="true">
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Tillägg (extensions): only when there's at least one */}
|
|
{visibleExtensionNavItems.length > 0 && (
|
|
<>
|
|
<div className="flex items-center gap-3 my-1.5 px-3">
|
|
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav('group_extensions')}</span>
|
|
<div className="flex-1 h-px bg-border/30" />
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{visibleExtensionNavItems.map((item) => {
|
|
const Icon = resolveIcon(item.icon)
|
|
const active = isActive(item.href)
|
|
const enabled = hasCompany
|
|
const labelTranslationKey = extensionLabelKey(item.href)
|
|
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
|
|
const content = (
|
|
<>
|
|
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
|
<span className="text-sm">{label}</span>
|
|
</>
|
|
)
|
|
const baseClass = cn(
|
|
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
|
|
enabled
|
|
? cn(
|
|
'transition-colors',
|
|
active
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
: 'text-foreground active:bg-muted/60'
|
|
)
|
|
: 'text-muted-foreground/40'
|
|
)
|
|
return enabled ? (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={closeMobileMenu}
|
|
className={baseClass}
|
|
>
|
|
{content}
|
|
</Link>
|
|
) : (
|
|
<div key={item.href} className={baseClass} aria-disabled="true">
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Mitt konto divider */}
|
|
<div className="flex items-center gap-3 my-1.5 px-3">
|
|
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav('mitt_konto')}</span>
|
|
<div className="flex-1 h-px bg-border/30" />
|
|
</div>
|
|
|
|
<div className="space-y-0.5">
|
|
{([
|
|
{ href: '/settings', labelKey: 'settings' as NavLabelKey, icon: Settings },
|
|
{ href: '/help', labelKey: 'help' as NavLabelKey, icon: HelpCircle },
|
|
]).map((item) => {
|
|
const Icon = item.icon
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href)
|
|
const content = (
|
|
<>
|
|
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
|
<span className="text-sm">{tNav(item.labelKey)}</span>
|
|
</>
|
|
)
|
|
const baseClass = cn(
|
|
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
|
|
enabled
|
|
? cn(
|
|
'transition-colors',
|
|
active
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
: 'text-foreground active:bg-muted/60'
|
|
)
|
|
: 'text-muted-foreground/40'
|
|
)
|
|
return enabled ? (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={closeMobileMenu}
|
|
className={baseClass}
|
|
>
|
|
{content}
|
|
</Link>
|
|
) : (
|
|
<div key={item.href} className={baseClass} aria-disabled="true">
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Support + Logout */}
|
|
<div className="px-2 py-2 mt-1 border-t border-border space-y-1">
|
|
<div className="px-3 py-2">
|
|
<SupportLink variant="muted" />
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-start text-muted-foreground active:text-foreground text-sm h-11 px-3"
|
|
onClick={() => {
|
|
closeMobileMenu()
|
|
handleLogout()
|
|
}}
|
|
>
|
|
<LogOut className="mr-3 h-[18px] w-[18px]" />
|
|
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|