b1f71912a2
* feat(analytics): show static nav chrome in session replays Replays previously masked every text node via maskTextSelector '*', which made them unreadable: even sidebar labels and buttons were asterisks. Add a fail-safe maskTextFn: text stays masked unless its nearest tagged ancestor is data-ph-unmask, and data-ph-mask re-masks user data nested inside an unmasked container. Untagged text stays masked, so a forgotten tag can never leak user data. Tags the four nav containers and the skip link; the notification count bubbles inside them are re-masked. User menu, company switcher and page titles stay masked on purpose: on detail pages the title is user data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(analytics): use English domain terms in masking comment CodeRabbit: comment used 'enskild firma' and 'personnummer'; repo guideline is English for all comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1239 lines
52 KiB
TypeScript
1239 lines
52 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useRef } from 'react'
|
|
import Link from 'next/link'
|
|
import Image from 'next/image'
|
|
import { usePathname, useRouter } from 'next/navigation'
|
|
import { useTranslations } from 'next-intl'
|
|
import { cn } from '@/lib/utils'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
LayoutDashboard,
|
|
Home,
|
|
ReceiptText,
|
|
Users,
|
|
ArrowLeftRight,
|
|
BookOpen,
|
|
ListTree,
|
|
BarChart3,
|
|
Settings,
|
|
LogOut,
|
|
Upload,
|
|
Inbox,
|
|
Menu,
|
|
X,
|
|
HelpCircle,
|
|
Building2,
|
|
Wallet,
|
|
TrendingUp,
|
|
ClipboardCheck,
|
|
HandCoins,
|
|
Package,
|
|
Tag,
|
|
Tags,
|
|
ChevronRight,
|
|
Clock,
|
|
Sparkles,
|
|
Percent,
|
|
Landmark,
|
|
CalendarClock,
|
|
CalendarRange,
|
|
FileCheck,
|
|
FileSpreadsheet,
|
|
ScrollText,
|
|
PanelLeft,
|
|
PanelLeftClose,
|
|
Library,
|
|
BookCheck,
|
|
} from 'lucide-react'
|
|
import { getBranding } from '@/lib/branding/service'
|
|
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
|
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
|
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
|
|
import { SupportLink } from '@/components/ui/support-link'
|
|
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
|
|
import UserMenu from '@/components/dashboard/UserMenu'
|
|
import AgentAvatar from '@/components/agent/AgentAvatar'
|
|
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
|
|
import { useCompany } from '@/contexts/CompanyContext'
|
|
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
|
|
import { useWorklistBadges } from '@/lib/hooks/use-worklist-badges'
|
|
import { persistUiState } from '@/lib/ui-state/client'
|
|
import { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys'
|
|
import type { EntityType, UserUiState } from '@/types'
|
|
|
|
void _ENABLED_EXTENSION_IDS
|
|
|
|
interface ExtensionNavItem {
|
|
href: string
|
|
label: string
|
|
icon: string
|
|
}
|
|
|
|
interface DashboardNavProps {
|
|
companyName: string
|
|
entityType: EntityType
|
|
// Whether the company has registered as an employer (company_settings.
|
|
// pays_salaries). Drives visibility of the payroll (Personal) section for
|
|
// non-aktiebolag, notably an enskild firma that hires staff. See #782.
|
|
paysSalaries?: boolean
|
|
// Whether the dimensions register (company_settings.dimensions_enabled) is
|
|
// switched on. Drives visibility of the Kostnadsställen & projekt row:
|
|
// same mechanism as paysSalaries: fetched by the dashboard layout.
|
|
dimensionsEnabled?: boolean
|
|
isSandbox?: boolean
|
|
extensionNavItems?: ExtensionNavItem[]
|
|
// Signed-in user's full name + email: drives the bottom-left account
|
|
// popover trigger so the user can see WHO they're logged in as,
|
|
// distinct from the active COMPANY shown by CompanySwitcher up top.
|
|
userName?: string | null
|
|
userEmail?: string | null
|
|
// Server-read user_preferences.ui_state: seeds sidebar collapse + fold
|
|
// state so the first client render matches the server-stamped width.
|
|
initialUiState?: UserUiState
|
|
}
|
|
|
|
type NavLabelKey =
|
|
| 'dashboard'
|
|
| 'home'
|
|
| 'assistant'
|
|
| 'agent_knowledge'
|
|
| 'kpi'
|
|
| 'invoice_inbox'
|
|
| 'invoices'
|
|
| 'customers'
|
|
| 'articles'
|
|
| 'supplier_invoices'
|
|
| 'suppliers'
|
|
| 'review'
|
|
| 'transactions'
|
|
| 'bookkeeping'
|
|
| 'chart_of_accounts'
|
|
| 'dimensions'
|
|
| 'assets'
|
|
| 'reports'
|
|
| 'import'
|
|
| 'salary'
|
|
| 'employees'
|
|
| 'vat_declaration'
|
|
| 'skattekonto'
|
|
| 'deadlines'
|
|
| 'periodiseringar'
|
|
| 'year_end'
|
|
| 'annual_report'
|
|
| 'income_declaration'
|
|
| 'help'
|
|
| 'settings'
|
|
|
|
// Nav layout (July 2026, UI-migration concept, dev_docs/ui_migration_plan.md
|
|
// PR 2): same routes, concept structure.
|
|
// top of rail : collapse toggle (64px icon rail when collapsed;
|
|
// state persists in user_preferences.ui_state).
|
|
// top section : flat, no header: Hem, Assistent (Flöden joins
|
|
// when the flow engine exists).
|
|
// four groups : static headers (Arbeta, Analys, Data,
|
|
// Skatt & bokslut); the Register (Data) and
|
|
// Bokslut (Skatt) sub-lists are animated folds.
|
|
// bottom user block : sticky; avatar + name + active company, opens
|
|
// the upward UserMenu with the company-switcher
|
|
// flyout, account links and logout.
|
|
// Help + Settings are NOT in `navItems`; they live in the user menu.
|
|
// Pending (Granskning) stays visible at all times: the badge carries the count.
|
|
type GroupKey = 'top' | 'arbeta' | 'analys' | 'data' | 'skatt'
|
|
// Folds: collapsible sub-lists inside a group (concept: Register under DATA,
|
|
// Bokslut under SKATT & BOKSLUT). Child rows render text-indented behind a
|
|
// hairline; open/closed state persists in user_preferences.ui_state.
|
|
type FoldKey = 'register' | 'bokslut'
|
|
|
|
interface NavItem {
|
|
href: string
|
|
labelKey: NavLabelKey
|
|
icon: typeof LayoutDashboard
|
|
group: GroupKey
|
|
// When set, the item renders inside this fold (consecutive items with the
|
|
// same fold key form one fold block at that position in the group).
|
|
fold?: FoldKey
|
|
// Payroll surfaces: visible only to employers: every aktiebolag (unchanged
|
|
// behaviour) plus any company that has registered as an employer via
|
|
// company_settings.pays_salaries (e.g. an enskild firma with staff). #782
|
|
employerOnly?: boolean
|
|
// Dimension surfaces: visible only when the company has opted in via
|
|
// company_settings.dimensions_enabled (UI-visibility gate only; the pages
|
|
// and APIs work regardless, dimensions plan §2).
|
|
requiresDimensions?: boolean
|
|
// Paywall surfaces: hidden unless the active company holds this paid
|
|
// capability. Cosmetic only, the page and API gates are the real
|
|
// enforcement; this just keeps the sidebar honest for non-payers.
|
|
requiredCapability?: CapabilityKey
|
|
// Statutory surfaces that only exist for one company form (INK2 vs
|
|
// NE-bilaga, årsredovisning): hidden for the other entity type.
|
|
entityOnly?: EntityType
|
|
hidden?: boolean
|
|
comingSoon?: boolean
|
|
devBadge?: boolean
|
|
betaBadge?: boolean
|
|
}
|
|
|
|
// Nav layout per the UI-migration concept (ui_migration_plan.md PR 2):
|
|
// same destinations, concept ordering, with the Register and Bokslut
|
|
// sub-lists as folds.
|
|
const navItems: NavItem[] = [
|
|
// Top section: flat list, always visible, no header. (Flöden joins here
|
|
// when the flow engine exists.)
|
|
{ href: '/', labelKey: 'home', icon: Home, group: 'top' },
|
|
{ href: '/chat', labelKey: 'assistant', icon: Sparkles, group: 'top' },
|
|
// Arbeta: everything the user produces, bookkeeping funnel first
|
|
// (Bokföring · Underlag · Transaktioner · Granskning), then the
|
|
// transactional flows. employerOnly: aktiebolag or pays_salaries. #782
|
|
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'arbeta' },
|
|
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'arbeta', requiredCapability: EXTENSION_REQUIRED_CAPABILITY['general/invoice-inbox'] },
|
|
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'arbeta' },
|
|
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' },
|
|
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
|
|
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
|
|
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true },
|
|
// Analys: read the numbers.
|
|
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' },
|
|
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' },
|
|
// Data: the Register fold (master data) + Importera/exportera as its own
|
|
// row. Anställda is a register (you edit an employee rarely, you run
|
|
// payroll monthly), so it lives here while Löner stays in Arbeta.
|
|
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'data', fold: 'register' },
|
|
{ href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'data', fold: 'register' },
|
|
{ href: '/articles', labelKey: 'articles', icon: Tag, group: 'data', fold: 'register' },
|
|
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'data', fold: 'register', employerOnly: true },
|
|
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'data', fold: 'register' },
|
|
{ href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'data', fold: 'register' },
|
|
{ href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'data', fold: 'register', requiresDimensions: true },
|
|
{ href: '/import', labelKey: 'import', icon: Upload, group: 'data' },
|
|
// Skatt & bokslut: everything submitted to the state; the year-end chain
|
|
// (periodiseringar → årsbokslut → årsredovisning → inkomstdeklaration)
|
|
// lives in the Bokslut fold in workflow order; the last two are
|
|
// entity-gated because the surface only exists for one company form.
|
|
{ href: '/reports/vat-declaration', labelKey: 'vat_declaration', icon: Percent, group: 'skatt' },
|
|
{ href: '/skattekonto', labelKey: 'skattekonto', icon: Landmark, group: 'skatt' },
|
|
{ href: '/deadlines', labelKey: 'deadlines', icon: CalendarClock, group: 'skatt' },
|
|
{ href: '/bookkeeping/periodiseringar', labelKey: 'periodiseringar', icon: CalendarRange, group: 'skatt', fold: 'bokslut' },
|
|
{ href: '/bookkeeping/year-end', labelKey: 'year_end', icon: FileCheck, group: 'skatt', fold: 'bokslut' },
|
|
{ href: '/bookkeeping/year-end/arsredovisning', labelKey: 'annual_report', icon: ScrollText, group: 'skatt', fold: 'bokslut', entityOnly: 'aktiebolag' },
|
|
{ href: '/reports/ink2-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', fold: 'bokslut', entityOnly: 'aktiebolag' },
|
|
{ href: '/reports/ne-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', fold: 'bokslut', entityOnly: 'enskild_firma' },
|
|
]
|
|
|
|
// Fold header presentation (label + icon). The fold rows themselves come
|
|
// from navItems entries carrying the matching `fold` key.
|
|
const foldConfig: Record<FoldKey, { labelKey: string; icon: typeof LayoutDashboard }> = {
|
|
register: { labelKey: 'fold_register', icon: Library },
|
|
bokslut: { labelKey: 'fold_bokslut', icon: BookCheck },
|
|
}
|
|
|
|
// Splits a group's items into a render sequence of plain items and fold
|
|
// blocks (consecutive same-fold items become one block).
|
|
type NavSegment =
|
|
| { type: 'item'; item: NavItem }
|
|
| { type: 'fold'; key: FoldKey; items: NavItem[] }
|
|
|
|
function segmentItems(items: NavItem[]): NavSegment[] {
|
|
const segments: NavSegment[] = []
|
|
for (const item of items) {
|
|
if (item.fold) {
|
|
const last = segments[segments.length - 1]
|
|
if (last && last.type === 'fold' && last.key === item.fold) {
|
|
last.items.push(item)
|
|
} else {
|
|
segments.push({ type: 'fold', key: item.fold, items: [item] })
|
|
}
|
|
} else {
|
|
segments.push({ type: 'item', item })
|
|
}
|
|
}
|
|
return segments
|
|
}
|
|
|
|
// Map known extension hrefs to nav translation keys so sidebar labels translate.
|
|
// Extensions whose manifest label happens to be English-ready can stay null.
|
|
function extensionLabelKey(href: string): string | null {
|
|
if (href === '/e/general/tic') return 'ext_tic'
|
|
if (href === '/e/general/invoice-inbox') return 'ext_invoice_inbox'
|
|
return null
|
|
}
|
|
|
|
const groupLabelKey: Record<Exclude<GroupKey, 'top'>, string> = {
|
|
arbeta: 'group_work',
|
|
analys: 'group_analysis',
|
|
data: 'group_data',
|
|
skatt: 'group_tax',
|
|
}
|
|
|
|
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) {
|
|
const pathname = usePathname()
|
|
const router = useRouter()
|
|
const supabase = useRealtimeSupabase()
|
|
const { company, capabilities, trialEndsAt } = useCompany()
|
|
// Agent identity drives the "Assistent" nav icon: when the user has
|
|
// built their assistant we show its chosen avatar instead of the
|
|
// generic Sparkles glyph.
|
|
const { identity: agentIdentity } = useAgentSheet()
|
|
const tNav = useTranslations('nav')
|
|
const tCommon = useTranslations('common')
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
|
const [isClosing, setIsClosing] = useState(false)
|
|
const closeTimerRef = useRef<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)
|
|
type ExpandableGroup = Exclude<GroupKey, 'top'>
|
|
|
|
// Sidebar collapse (64px icon rail). The width is CSS-variable-driven:
|
|
// #dash-shell sets --nav-w inline (server-rendered from ui_state), and
|
|
// both the aside and <main> read it, so one property flip resizes the
|
|
// whole shell in lockstep. The React state only drives which sidebar
|
|
// variant renders.
|
|
const [collapsed, setCollapsed] = useState(initialUiState?.nav_collapsed === true)
|
|
const toggleCollapsed = () => {
|
|
const next = !collapsed
|
|
setCollapsed(next)
|
|
document
|
|
.getElementById('dash-shell')
|
|
?.style.setProperty('--nav-w', next ? '64px' : '248px')
|
|
persistUiState({ nav_collapsed: next })
|
|
}
|
|
|
|
// Fold state (Register, Bokslut). Closed by default; an active child
|
|
// route forces its fold open so deep links never land in a hidden row.
|
|
const [foldsOpen, setFoldsOpen] = useState<Record<FoldKey, boolean>>({
|
|
register: initialUiState?.nav_folds?.register ?? false,
|
|
bokslut: initialUiState?.nav_folds?.bokslut ?? false,
|
|
})
|
|
const toggleFold = (key: FoldKey) => {
|
|
setFoldsOpen((prev) => {
|
|
const next = { ...prev, [key]: !prev[key] }
|
|
persistUiState({ nav_folds: { [key]: next[key] } })
|
|
return next
|
|
})
|
|
}
|
|
const isFoldOpen = (key: FoldKey, items: NavItem[]) =>
|
|
foldsOpen[key] || items.some((it) => isActive(it.href))
|
|
|
|
const openMobileMenu = () => {
|
|
if (closeTimerRef.current) {
|
|
clearTimeout(closeTimerRef.current)
|
|
closeTimerRef.current = null
|
|
}
|
|
setIsClosing(false)
|
|
setIsMobileMenuOpen(true)
|
|
}
|
|
|
|
const handleLogout = async () => {
|
|
resetAnalyticsIdentity()
|
|
await supabase.auth.signOut()
|
|
router.push(isSandbox ? '/sandbox' : '/login')
|
|
}
|
|
|
|
const isActive = (href: string) => {
|
|
if (href === '/') {
|
|
return pathname === '/'
|
|
}
|
|
if (href === '/salary') {
|
|
return pathname === '/salary' || pathname.startsWith('/salary/runs')
|
|
}
|
|
// Routes with their own rows under Skatt & bokslut (Bokslut, Moms,
|
|
// Periodiseringar, Årsredovisning, Inkomstdeklaration) are carved out
|
|
// of their parent routes so exactly one row lights up.
|
|
if (href === '/bookkeeping') {
|
|
return (
|
|
pathname.startsWith('/bookkeeping') &&
|
|
!pathname.startsWith('/bookkeeping/year-end') &&
|
|
!pathname.startsWith('/bookkeeping/periodiseringar')
|
|
)
|
|
}
|
|
if (href === '/bookkeeping/year-end') {
|
|
return (
|
|
pathname.startsWith('/bookkeeping/year-end') &&
|
|
!pathname.startsWith('/bookkeeping/year-end/arsredovisning')
|
|
)
|
|
}
|
|
if (href === '/reports') {
|
|
return (
|
|
pathname.startsWith('/reports') &&
|
|
!pathname.startsWith('/reports/vat-declaration') &&
|
|
!pathname.startsWith('/reports/ink2-declaration') &&
|
|
!pathname.startsWith('/reports/ne-declaration')
|
|
)
|
|
}
|
|
return pathname.startsWith(href)
|
|
}
|
|
|
|
const closeMobileMenu = () => {
|
|
setIsClosing(true)
|
|
closeTimerRef.current = setTimeout(() => {
|
|
setIsMobileMenuOpen(false)
|
|
setIsClosing(false)
|
|
closeTimerRef.current = null
|
|
}, 200)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!company?.id) return
|
|
|
|
// Realtime keeps the badges live; a trailing debounce collapses event
|
|
// bursts (bulk booking / bulk approvals emit one event per row) into a
|
|
// single SWR revalidation instead of a request stampede.
|
|
let debounce: ReturnType<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') },
|
|
]
|
|
|
|
// Flat leaf list for the collapsed 64px icon rail: fold children render
|
|
// as plain icons (group headers and fold headers disappear).
|
|
const railItems = [...topItems, ...sidebarGroups.flatMap(({ items }) => items)]
|
|
|
|
const allMobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = [
|
|
{ href: '/', labelKey: 'home', icon: Home },
|
|
{ href: '/chat', labelKey: 'assistant', icon: Sparkles },
|
|
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight },
|
|
]
|
|
// Same gate as the sidebar: no Assistent tab until the agent is built.
|
|
const mobileNavItems = allMobileNavItems.filter(
|
|
(item) => item.href !== '/chat' || agentIdentity.isVerified,
|
|
)
|
|
|
|
const renderBadge = (item: NavItem | { comingSoon?: boolean; devBadge?: boolean; betaBadge?: boolean }, position: 'sidebar' | 'mobile') => {
|
|
const baseClass =
|
|
position === 'sidebar'
|
|
? 'ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5'
|
|
: 'rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5'
|
|
if (item.comingSoon) return <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
|
|
}
|
|
|
|
const badgeFor = (href: string): number | null =>
|
|
href === '/transactions' && uncategorizedCount > 0
|
|
? uncategorizedCount
|
|
: href === '/pending' && pendingOpsCount > 0
|
|
? pendingOpsCount
|
|
: null
|
|
|
|
const countBubble = (badge: number) => (
|
|
<span data-ph-mask 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>
|
|
)
|
|
|
|
// One sidebar row (expanded sidebar). Fold children render text-indented
|
|
// without an icon behind the fold's hairline (concept PR 2).
|
|
const renderSidebarItem = (item: NavItem, opts?: { child?: boolean }) => {
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href) && !item.comingSoon
|
|
const badge = badgeFor(item.href)
|
|
const decorBadge = renderBadge(item, 'sidebar')
|
|
const content = (
|
|
<>
|
|
{!opts?.child &&
|
|
renderNavIcon(
|
|
item,
|
|
cn(
|
|
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
|
|
active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground',
|
|
),
|
|
)}
|
|
<span className="flex-1">{tNav(item.labelKey)}</span>
|
|
{decorBadge ? decorBadge : badge !== null && countBubble(badge)}
|
|
</>
|
|
)
|
|
const baseClass = cn(
|
|
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
|
|
enabled
|
|
? cn(
|
|
'transition-colors duration-150',
|
|
active
|
|
? 'bg-secondary text-foreground font-medium'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60',
|
|
)
|
|
: 'text-muted-foreground/40 cursor-not-allowed',
|
|
)
|
|
return enabled ? (
|
|
<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>
|
|
)
|
|
}
|
|
|
|
// Fold block: header row + grid-rows 0fr/1fr height animation, children
|
|
// indented behind a hairline left edge.
|
|
const renderFold = (segKey: FoldKey, items: NavItem[]) => {
|
|
const open = isFoldOpen(segKey, items)
|
|
const cfg = foldConfig[segKey]
|
|
const FoldIcon = cfg.icon
|
|
return (
|
|
<div key={segKey}>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleFold(segKey)}
|
|
aria-expanded={open}
|
|
className="group flex w-full items-center px-3 py-[7px] text-[13px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
|
|
>
|
|
<FoldIcon className="mr-2.5 h-[15px] w-[15px] flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
|
|
<span className="flex-1 text-left">{tNav(cfg.labelKey)}</span>
|
|
<ChevronRight
|
|
className={cn('h-3 w-3 transition-transform duration-200', open && 'rotate-90')}
|
|
/>
|
|
</button>
|
|
<div
|
|
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
|
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
|
>
|
|
<div className="overflow-hidden">
|
|
<div className="ml-[19px] border-l border-border pl-1.5 py-px space-y-px">
|
|
{items.map((item) => renderSidebarItem(item, { child: true }))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Collapsed 64px rail: icon-only rows, native title tooltips, count
|
|
// bubbles pinned to the icon corner.
|
|
const renderRailItem = (
|
|
item: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard; comingSoon?: boolean },
|
|
) => {
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href) && !item.comingSoon
|
|
const badge = badgeFor(item.href)
|
|
const label = tNav(item.labelKey)
|
|
const inner = (
|
|
<span className="relative">
|
|
{renderNavIcon(
|
|
item,
|
|
cn('h-[17px] w-[17px]', active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'),
|
|
)}
|
|
{badge !== null && (
|
|
<span data-ph-mask className="absolute -top-2 -right-2.5 min-w-[15px] h-[15px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
|
|
{badge > 99 ? '99' : badge}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)
|
|
const baseClass = cn(
|
|
'group flex h-9 w-9 items-center justify-center rounded-lg',
|
|
enabled
|
|
? cn('transition-colors duration-150', active ? 'bg-secondary' : 'hover:bg-secondary/60')
|
|
: 'opacity-40 cursor-not-allowed',
|
|
)
|
|
return enabled ? (
|
|
<Link key={item.href} href={item.href} className={baseClass} title={label} aria-label={label}>
|
|
{inner}
|
|
</Link>
|
|
) : (
|
|
<div key={item.href} className={baseClass} title={label} aria-disabled="true">
|
|
{inner}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Desktop sidebar */}
|
|
{/* Width, and the panel margin beside it, share the same 300ms
|
|
decelerating curve so collapse reads as one movement. */}
|
|
<aside className="hidden md:fixed md:inset-y-0 md:z-10 md:flex md:w-[var(--nav-w)] md:flex-col md:transition-[width] md:duration-300 md:ease-[cubic-bezier(0.32,0.72,0,1)]">
|
|
{/* Borderless on the frame: the panel next to it carries the border */}
|
|
<div className="flex min-h-0 flex-1 flex-col bg-transparent">
|
|
{/* Sidebar header: brand mark left, collapse toggle right. The
|
|
concept hangs the nav column and the panel from the same top
|
|
line, so this row sits level with the panel's top edge. */}
|
|
<div
|
|
className={cn(
|
|
'flex flex-shrink-0 items-center pt-3 pb-2',
|
|
collapsed
|
|
? 'flex-col justify-center gap-1'
|
|
: 'justify-between pl-5 pr-3',
|
|
)}
|
|
>
|
|
<Link
|
|
href="/"
|
|
aria-label={getBranding().appName}
|
|
className="flex items-center rounded-lg"
|
|
>
|
|
<Image
|
|
src={getBranding().logoPath}
|
|
alt=""
|
|
width={26}
|
|
height={26}
|
|
className="h-[26px] w-[26px] rounded-md"
|
|
/>
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={toggleCollapsed}
|
|
aria-label={collapsed ? tNav('expand_nav') : tNav('collapse_nav')}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
|
|
>
|
|
{collapsed ? (
|
|
<PanelLeft className="h-4 w-4" />
|
|
) : (
|
|
<PanelLeftClose className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Nav items in their own scroll container so the user block
|
|
below stays sticky (concept PR 2). */}
|
|
<div className="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden pt-1 pb-2">
|
|
{/* Both states stay mounted and crossfade past each other while
|
|
the aside width animates: no DOM swap, one continuous motion.
|
|
The inactive layer is absolute (no layout), faded, nudged
|
|
sideways and inert. */}
|
|
{/* data-ph-unmask: nav labels are static i18n chrome; count
|
|
bubbles inside carry data-ph-mask (nearest tag wins). */}
|
|
<nav
|
|
data-ph-unmask
|
|
aria-hidden={!collapsed}
|
|
inert={!collapsed ? true : undefined}
|
|
className={cn(
|
|
'flex w-16 flex-col items-center gap-px px-2 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
|
|
collapsed
|
|
? 'opacity-100 translate-x-0'
|
|
: 'pointer-events-none absolute inset-x-0 top-1 opacity-0 -translate-x-3',
|
|
)}
|
|
aria-label={tNav('main_navigation')}
|
|
>
|
|
{railItems.map((item) => renderRailItem(item))}
|
|
{visibleExtensionNavItems.map((item) => {
|
|
const Icon = resolveIcon(item.icon)
|
|
const labelTranslationKey = extensionLabelKey(item.href)
|
|
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
|
|
const active = isActive(item.href)
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
title={label}
|
|
aria-label={label}
|
|
className={cn(
|
|
'group flex h-9 w-9 items-center justify-center rounded-lg transition-colors duration-150',
|
|
active ? 'bg-secondary' : 'hover:bg-secondary/60',
|
|
)}
|
|
>
|
|
<Icon
|
|
className={cn(
|
|
'h-[17px] w-[17px]',
|
|
active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground',
|
|
)}
|
|
/>
|
|
</Link>
|
|
)
|
|
})}
|
|
</nav>
|
|
<nav
|
|
data-ph-unmask
|
|
aria-hidden={collapsed}
|
|
inert={collapsed ? true : undefined}
|
|
className={cn(
|
|
'w-[248px] px-3 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
|
|
collapsed
|
|
? 'pointer-events-none absolute inset-x-0 top-1 opacity-0 translate-x-3'
|
|
: 'opacity-100 translate-x-0',
|
|
)}
|
|
aria-label={tNav('main_navigation')}
|
|
>
|
|
{/* Top section: flat, no header. Hem, Assistent. */}
|
|
<div className="mb-4 space-y-px">
|
|
{topItems.map((item) => renderSidebarItem(item))}
|
|
</div>
|
|
|
|
{/* Groups: Arbeta, Analys, Data, Skatt & bokslut. Static
|
|
headers; only the Register/Bokslut folds collapse. */}
|
|
{sidebarGroups
|
|
.filter(({ items }) => items.length > 0)
|
|
.map(({ key, items }) => (
|
|
<div key={key} className="mb-4">
|
|
<div className="px-3 pb-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
|
|
{tNav(groupLabelKey[key])}
|
|
</div>
|
|
<div className="space-y-px">
|
|
{segmentItems(items).map((seg) =>
|
|
seg.type === 'item'
|
|
? renderSidebarItem(seg.item)
|
|
: renderFold(seg.key, seg.items),
|
|
)}
|
|
{/* 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). */}
|
|
{!collapsed && !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>
|
|
)}
|
|
|
|
{/* Sticky user block (bottom-left): avatar, name, active company.
|
|
Opens the upward user menu with the company-switcher flyout,
|
|
account links and logout (concept PR 2). The hairline above is
|
|
inset to the content edges, not edge-to-edge (concept). */}
|
|
<div className="flex-shrink-0">
|
|
<div
|
|
className={cn(
|
|
'border-t border-border/60',
|
|
collapsed ? 'mx-2' : 'mx-3',
|
|
)}
|
|
/>
|
|
<div className={cn(collapsed ? 'px-2 py-2' : 'px-3 py-2')}>
|
|
<UserMenu
|
|
userName={userName}
|
|
userEmail={userEmail}
|
|
isSandbox={isSandbox}
|
|
collapsed={collapsed}
|
|
onLogout={() => void handleLogout()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Mobile bottom navigation */}
|
|
<nav data-ph-unmask 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 data-ph-mask 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. data-ph-unmask: static i18n labels only; the
|
|
CompanySwitcher above stays outside so it remains masked,
|
|
and count bubbles inside carry data-ph-mask. */}
|
|
<div data-ph-unmask 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 data-ph-mask 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 data-ph-mask 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>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|