* feat(analytics): remove Recapt, PostHog is now the only analytics
Recapt shuts down in days. Everything it did is covered by the PostHog
integration in the previous commit, so the SDK, its five modules and its
CSP hosts come out.
Deleted: RecaptLoader, RecaptHideWidget, RecaptIdentify, lib/recapt.ts,
types/recapt.d.ts. Unmounted from app/layout.tsx (the <script> in <head>
and the widget-hider) and from app/(dashboard)/layout.tsx. Both logout
handlers already call resetAnalyticsIdentity() and now only that.
The CSP gets strictly narrower: connect-src loses api.recapt.app and
cdn.recapt.app, script-src loses cdn.recapt.app, and nothing is added in
their place, because PostHog runs through the same-origin /rl rewrite.
Verified against the built routes-manifest.
Behaviour change worth calling out: lib/support/submit-feedback.ts is now
single-channel. Recapt used to accept the message through its own SDK, so
a failing /api/support/contact still reported success to the user. Email
is now the only delivery path and its failure is visible. That is the
right outcome, silently "succeeding" while the message reached nobody was
worse, and the Resend path is solid. A non-blocking
posthog.capture('support_feedback_submitted') keeps the useful half of
the old dual-channel behaviour by putting the submission on the user's
timeline next to the session replay; it carries no message body, since
free text is user content and would be PII in an event property. The six
Recapt-specific test cases are replaced with the email-only contract plus
coverage of the breadcrumb, the self-hosted skip, and a throwing SDK not
breaking delivery.
Compliance, which Recapt never had: the privacy page sub-processor row is
replaced (not just deleted) with an accurate PostHog row, and .compliance/
ropa.yaml gains a product.analytics activity. The old row also claimed
Recapt loaded "endast for inloggade anvandare", which was never true,
RecaptLoader sat in the root <head> on every page including logged-out
ones. The new row describes what actually happens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(analytics): purge Recapt storage left on users' devices
Removing the Recapt <script> stops it writing anything new, but every
browser that already loaded the app keeps what it persisted. Observed on
production after #1237: localStorage still holds
`__recapt_record_engine`, and after this PR nothing would ever remove it,
because the helper that used to sweep on logout (lib/recapt.ts
clearRecaptIdentity) is deleted along with the SDK.
Inert data, but it is third-party storage from a processor the privacy
page now says we no longer use, and the whole point of the PostHog
config is that nothing is stored on the device. So clear it.
Matching is by substring rather than prefix on purpose: the old sweep
tested key.startsWith('recapt'), which never actually matched the real
key, since `__recapt_record_engine` starts with underscores. A test pins
that. The app's own keys (Accounted:chat-sidebar-collapsed,
gnubok.inbox.onboarding.dismissed) contain neither marker.
Runs unconditionally from instrumentation-client.ts, before the
analytics gate, so a browser gets cleaned even on a build where PostHog
is switched off. Iterates backwards because removeItem() re-indexes the
store and a forward loop would skip entries; both covered by tests, along
with private-mode throws and the server no-op.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1233 lines
52 KiB
TypeScript
1233 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 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 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. */}
|
|
<nav
|
|
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
|
|
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 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>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|