Files
accounted/components/dashboard/DashboardNav.tsx
T
MattssonandClaude Fable 5 b6332e9ff4 Fix/skv connection flow (#1015)
* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:54:33 +02:00

1138 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),
// reachable via /settings/assistant?view=knowledge, 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">
<div className="flex min-h-0 flex-1 flex-col border-r border-border bg-background">
<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>
</>
)}
</>
)
}