* feat(bookkeeping): concept toolbar, template booking, confirm-first posting (UI migration PR 4)
The Bokforing page adopts the concept (scene 9) on top of the PR 3 kit:
- Toolbar in concept order with the FyPicker chip far right replacing the
"Visar:" scope selector (same persisted scope, one-click change)
- "Nytt verifikat" is a SplitButton with three remembered modes: Tomt
verifikat (existing editor, voucher-number hint kept), Bokfor fran mall
(new centered TemplateBookDialog: existing booking_template_library
data MRU-ordered, date + editable amount recomputing the kontering
live via applyTemplate, Balanserar row, direct booking + MRU touch),
and Skapa med assistenten (existing agent-sheet path; suggestion lands
in Granskning). Last-used mode persists via ui_state.create_mode
- Draft posting goes through ConfirmDialog describing the outcome
("Bokfors som verifikat A-218: ...") with an indicative next-voucher
preview; the success toast still shows the real number
- "Underlag saknas" becomes the row's only warning chip (Badge warning)
instead of the bare triangle icon; exempt rows keep the muted glyph
- New lib/hooks/use-ui-state.ts: client read of ui_state to seed the
split button's initial mode
No backend, migration or RPC changes. VAT-split math is applyTemplate,
already unit-tested in lib/bookkeeping/__tests__/template-library.test.ts;
split-button persistence is tested in lib/ui-state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bookkeeping): use roundOre in TemplateBookDialog money math
The antipattern ratchet caught two hand-rolled Math.round(x*100)/100;
route them through lib/money roundOre like the rest of the codebase.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: PR 4 decisions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(bookkeeping): concept dry-table verifikat list (scene 9)
The list itself adopts the concept, verified against the artifact's
scene 9 markup: a borderless table (Verifikation / Datum / Beskrivning /
Belopp) with hover-revealed selection checkboxes, hover-revealed chevron,
and an animated grid-rows row expansion whose kontering renders as the
concept's vlines sub-table (uppercase hairline heads, Summa row).
Expansion actions become quiet underlined links (Visa detaljer, Skapa
andringsverifikation, Aterfor (storno), Kopiera); posting keeps its pill
+ ConfirmDialog. Drafts get a row-level Bokfor button like the concept.
All functionality preserved: batch "Inget underlag kravs" bar (above the
table), attachment counts + preview, no-doc-required toggle, out-of-
period + status badges, FX line amounts, sum footer, pagination. The
density toggle is dropped: the table has one density by design.
Fixes from verification: the list's i18n lives in the journal_list
namespace (new keys moved there; they rendered as raw keys otherwise),
and the sidebar brand Image gets explicit dimensions (Next dev warning).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bookkeeping): bulkbar appears only when a verifikat is selected
Concept behavior: no standing "Markera alla (62) / Markera alla utan
underlag" bar. The batch bar is hidden until the first row is selected
via its hover checkbox, then pops in with the count, the reason input,
Undanta underlagskrav, and quiet actions for Markera alla, the
filter-scoped bulk mark, and Avmarkera. All batch functionality kept,
just no chrome until it is needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1209 lines
51 KiB
TypeScript
1209 lines
51 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 { clearRecaptIdentity } from '@/lib/recapt'
|
|
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 () => {
|
|
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') },
|
|
]
|
|
|
|
// 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 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-200 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 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 */}
|
|
<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-200">
|
|
{/* 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="flex-1 min-h-0 overflow-y-auto pt-1 pb-2">
|
|
{collapsed ? (
|
|
<nav
|
|
className="flex flex-col items-center gap-px px-2"
|
|
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 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) => 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). */}
|
|
<div
|
|
className={cn(
|
|
'flex-shrink-0 border-t border-border/60',
|
|
collapsed ? 'px-2 py-2' : 'px-3 py-2',
|
|
)}
|
|
>
|
|
<UserMenu
|
|
userName={userName}
|
|
userEmail={userEmail}
|
|
isSandbox={isSandbox}
|
|
collapsed={collapsed}
|
|
onLogout={() => void handleLogout()}
|
|
/>
|
|
</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>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|