c0818bb2d2
* feat(sales-orders): kundorder with partial delivery and partial invoicing Adds sales orders (kundorder) as their own non-ledger document between agreement and invoice, for companies that deliver or invoice in parts. Schema (20260902130000): sales_orders + sales_order_items with RLS via user_company_ids(), OR-<n> numbering RPC (membership-gated, no anon execute), company_settings.sales_orders_enabled UI gate, and back-links invoices.sales_order_id / invoice_items.sales_order_item_id. The invoiced quantity per order line is DERIVED from the linked invoice lines on non-cancelled, non-credited invoices and enforced by a BEFORE trigger, so no counter can drift and a credited invoice frees its quantity. Header status is draft / confirmed / completed / cancelled; completion is kept by DB triggers from the same derived quantity. Delivery and invoicing progress are derived per line, never stored as status. Service + API: lib/sales-orders (create/update with id-preserving line replace, transitions with compare-and-set, cumulative delivery registration, invoice-from-order through buildInvoiceWriteData so booking stays in the engine, proforma -> order conversion), routes under /api/sales-orders and /api/invoices/[id]/convert-to-order, structured SALES_ORDER_* error codes, archive classification of the new tables. The invoice editor round-trips sales_order_item_id so a draft edit cannot drop the link; GET /api/invoices gains ?sales_order_id=. UI: /sales-orders list, create/edit form reusing the invoice line conventions, detail with deliver and create-invoice dialogs and linked invoices; nav row behind the settings toggle; the webshop row is relabelled webshop_orders; "Skapa order" on proformas. MCP (20260902141000/141001): list/get reads plus four staged writes (create, transition, register delivery, create invoice from order) whose executors call the lib services; op types added to the pending operations CHECK. Tests: route tests for every route (401/400/404/happy), service unit tests, executor and tool tests, and tests/pg/sales-orders.pg.test.ts (16 cases, green on staging) covering RLS, numbering guards, the over-invoice trigger incl. release on cancel/credit and cross-company refusal, the quantity floor, and completion maintenance. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQW7mXvbAPgjUHq7dSEamr * fix(sales-orders): harden kundorder after skeptic and security review Resolves every finding from the PR #2166 review pass in one batch. Order link integrity: replaceInvoiceItems now refuses a line set that drops an existing sales_order_item_id (INVOICE_UPDATE_DROPS_ORDER_LINK), closing the MCP update_invoice header-only edit and the v1 PATCH path that severed the link and freed the quantity for double invoicing. The update_invoice re-fetch, gnubok_get_invoice and the v1 item projection now carry sales_order_item_id so well-behaved clients round-trip it. Quantity math: derived remaining/invoiced quantities are rounded to six decimals and compared with an epsilon (roundQty, qtyGreater) so a float remainder such as 0.5999999999999996 can neither refuse the final partial invoice nor land as an invoice quantity; duplicate explicit picks are summed before validation. Leveransdatum: per-line last_delivery_date (migration 20260902160000); an invoice takes the latest date over the lines it covers and only when the covered quantity was delivered, never the header date and never for an advance invoice (ML 17 kap 24 p.7, FX anchor per ML 8 kap 21-23). VAT drift: the order stores the customer type and VAT-validation flag its lines were priced under; invoicing refuses with SALES_ORDER_CUSTOMER_VAT_CHANGED when they differ, and re-saving the order re-validates the lines. Customer and currency are frozen once invoices exist. Tenant and role gates: composite FK (sales_order_id, company_id) ties a line to its parent's company (Superagent P2); aa_enforce_company_writer_role on both tables so a viewer cannot write through the browser client. Proforma -> order refuses proformas with ROT/RUT, periodisering or negative-quantity lines instead of dropping those fields. RESTRICT FK errors on delete map to SALES_ORDER_LINE_LOCKED / SALES_ORDER_HAS_INVOICES. Also: schema-guard literal payloads in lib/sales-orders (ceiling +2 with reason), regenerated skills/accounted-api (sales_order_item_id on invoice items), pg tests for the composite FK, the viewer gate and the new columns, unit tests for every changed path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): resolve CodeRabbit round on PR #2166 Quick wins from the review, all in one pass: - replaceInvoiceItems fails closed when the invoice_items snapshot cannot be read (it is both the restore source and the input to the kundorder link guard); the guard branch is explicit in both PATCH routes. - Cumulative delivery registration carries an optimistic predicate on the quantity it read, so two concurrent registrations cannot regress each other; DELETE of an order keeps its allowed status in the predicate and answers a conflict when zero rows match. - Business dates (order date, delivery date, invoice date) default to the Europe/Stockholm calendar day (todayIsoStockholm), never UTC: the delivery date is also the Riksbanken rate anchor. - The invoice-from-order executor treats an event emit failure as non-blocking: the draft already exists. - sales_order_items are archived through their parent with the order currency denormalised, like invoice_items. - Proforma "Skapa order" tolerates a 2xx without a parsable body; the settings toggle refreshes the server-rendered nav. - List route doc states that q matches the order number (customer names are matched client-side). Declined (out of scope for this PR): moving header + line writes and the delivery loop into transactional RPCs (same PostgREST pattern as the invoice PATCH path, tracked as a follow-up), the MCP approval handler's error message shape (pre-existing code outside this change), and the docstring-coverage warning (no repo convention). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): move hardening migration off a colliding version; archive contract; ceiling - 20260902160000_sales_orders_hardening.sql collided with main's 20260902160000_parties_substrate.sql after the third sync; renamed to 20260902180000 and made idempotent (DROP ... IF EXISTS before each ADD CONSTRAINT) so a preview branch that applied it under the old version replays it cleanly. Staging's schema_migrations row renamed. - sales_order_items goes back to a direct archive dump: the coverage contract (tests/pg/full-archive-coverage.pg.test.ts) requires it for a table with its own company_id; the currency lives on the parent order one file over, joined by sales_order_id. - Scanner ceiling re-baselined after merging main (parties phase 1): 397. - v1 PATCH test queues a real empty invoice_items snapshot now that replaceInvoiceItems fails closed on an unreadable one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): drop the composite FK before its unique index on replay The idempotent guard in 20260902180000_sales_orders_hardening.sql dropped the unique (id, company_id) before the FK that depends on its index, so the preview branch replay (which had applied the file under its former version) failed with SQLSTATE 2BP01. Order swapped; replay verified on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1435 lines
64 KiB
TypeScript
1435 lines
64 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useRef } from 'react'
|
|
import { NavLink } from './NavLink'
|
|
import { usePathname, useRouter, useSearchParams } 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,
|
|
Sparkles,
|
|
Percent,
|
|
Landmark,
|
|
Scale,
|
|
CalendarClock,
|
|
CalendarRange,
|
|
FileCheck,
|
|
FileSpreadsheet,
|
|
ScrollText,
|
|
PanelLeft,
|
|
PanelLeftClose,
|
|
Library,
|
|
BookCheck,
|
|
Briefcase,
|
|
ArrowLeft,
|
|
Workflow,
|
|
FolderArchive,
|
|
ShoppingCart,
|
|
Car,
|
|
ClipboardList,
|
|
} from 'lucide-react'
|
|
import { getBranding } from '@/lib/branding/service'
|
|
import { BrandHomeLink } from '@/components/branding/BrandHomeLink'
|
|
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 SubscriptionTouchpoint from '@/components/billing/SubscriptionTouchpoint'
|
|
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
|
|
// Whether kundorder (company_settings.sales_orders_enabled) is switched on.
|
|
// Drives visibility of the Kundorder row: same mechanism as
|
|
// dimensionsEnabled, fetched by the dashboard layout.
|
|
salesOrdersEnabled?: boolean
|
|
// Whether the company has a webshop hooked up (active WooCommerce/Shopify
|
|
// connection, or existing webshop_orders rows). Drives visibility of the
|
|
// Order row: same mechanism as paysSalaries, fetched by the layout.
|
|
hasWebshop?: boolean
|
|
// Whether the Körjournal row shows: the company_settings.mileage_enabled
|
|
// toggle OR existing mileage_trips rows (trips created via API/MCP must
|
|
// stay reachable). Computed by the dashboard layout.
|
|
hasMileage?: 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'
|
|
| 'sales_orders'
|
|
| 'webshop_orders'
|
|
| 'customers'
|
|
| 'articles'
|
|
| 'supplier_invoices'
|
|
| 'suppliers'
|
|
| 'review'
|
|
| 'transactions'
|
|
| 'reconciliation'
|
|
| 'bookkeeping'
|
|
| 'chart_of_accounts'
|
|
| 'dimensions'
|
|
| 'assets'
|
|
| 'reports'
|
|
| 'import'
|
|
| 'salary'
|
|
| 'mileage'
|
|
| 'employees'
|
|
| 'vat_declaration'
|
|
| 'skattekonto'
|
|
| 'deadlines'
|
|
| 'periodiseringar'
|
|
| 'year_end'
|
|
| 'bokslutsbilagor'
|
|
| 'annual_report'
|
|
| 'income_declaration'
|
|
| 'help'
|
|
| 'settings'
|
|
| 'clients'
|
|
| 'automations'
|
|
| 'back_to_clients'
|
|
|
|
// 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
|
|
// Kundorder surfaces: visible only when the company has opted in via
|
|
// company_settings.sales_orders_enabled (UI-visibility gate only; the
|
|
// pages and APIs work regardless).
|
|
requiresSalesOrders?: boolean
|
|
// Webshop surfaces: visible only when the company has an active
|
|
// WooCommerce/Shopify connection or already-imported order rows.
|
|
// UI-visibility gate only; the page and APIs work regardless.
|
|
requiresWebshop?: boolean
|
|
// Körjournal surfaces: visible only when the company has opted in via the
|
|
// bookkeeping settings toggle (company_settings.mileage_enabled) or already
|
|
// has trips. UI-visibility gate only; the page and APIs work regardless.
|
|
requiresMileage?: 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
|
|
// Byrå cockpit surfaces (WL-14): visible only to byrå team members
|
|
// (teams.kind = 'byra'). The /clients page + API enforce server-side.
|
|
byraOnly?: boolean
|
|
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' },
|
|
// Byrå cockpit (WL-14): the client list, byrå team members only.
|
|
{ href: '/clients', labelKey: 'clients', icon: Briefcase, group: 'top', byraOnly: true },
|
|
{ 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: '/reconciliation', labelKey: 'reconciliation', icon: Scale, group: 'arbeta' },
|
|
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' },
|
|
// Kundorder: opt-in via the bookkeeping settings toggle (UI gate only).
|
|
{ href: '/sales-orders', labelKey: 'sales_orders', icon: ClipboardList, group: 'arbeta', requiresSalesOrders: true },
|
|
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
|
|
// Webshop orders: visible only for companies that actually have a webshop
|
|
// hooked up (active WooCommerce/Shopify connection or existing order rows).
|
|
// Deliberately NOT capability-gated: a company whose entitlement lapsed
|
|
// must still reach its already-imported orders (accounting underlag).
|
|
{ href: '/orders', labelKey: 'webshop_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true, betaBadge: true },
|
|
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
|
|
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true },
|
|
// Körjournal: hidden by default (most companies have no car); shows when
|
|
// the settings toggle is on or trips already exist (hybrid gate, same
|
|
// "data stays reachable" reasoning as the Order row above).
|
|
{ href: '/mileage', labelKey: 'mileage', icon: Car, group: 'arbeta', requiresMileage: 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: '/reports/bokslutsbilagor', labelKey: 'bokslutsbilagor', icon: FolderArchive, 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' },
|
|
]
|
|
|
|
// Byrå cockpit sidebar (lean mode): while a byrå team member is on a
|
|
// cockpit route (client overview + the /byra pages) the sidebar shows only
|
|
// these four byrå-scope entries instead of the full company nav. Entering a
|
|
// company (performCompanySwitch from the client list) is a hard navigation
|
|
// to a company route, which swaps the full list back in.
|
|
const cockpitNavItems: NavItem[] = [
|
|
{ href: '/byra', labelKey: 'home', icon: Home, group: 'top' },
|
|
{ href: '/clients', labelKey: 'clients', icon: Briefcase, group: 'top' },
|
|
{ href: '/byra/automations', labelKey: 'automations', icon: Workflow, group: 'top' },
|
|
{ href: '/byra/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'top' },
|
|
]
|
|
|
|
// Cockpit routes are byrå-scope, not company-scope: they decide which
|
|
// sidebar variant renders and stay enabled without an active company.
|
|
const COCKPIT_PATHS = ['/byra', '/clients']
|
|
const isCockpitPath = (pathname: string) =>
|
|
COCKPIT_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`))
|
|
|
|
// 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, salesOrdersEnabled = false, hasWebshop = false, hasMileage = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) {
|
|
const pathname = usePathname()
|
|
const router = useRouter()
|
|
const supabase = useRealtimeSupabase()
|
|
const { company, capabilities, byraTeam } = 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 tSwitcher = useTranslations('company_switcher')
|
|
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)
|
|
const hasCompany = !!company
|
|
// Byrå cockpit mode: lean sidebar on cockpit routes, full sidebar (with a
|
|
// back-to-clients link) once the member is inside a company. Settings opens
|
|
// as a modal OVER the current surface (intercepted route), so while the
|
|
// pathname is /settings/* the sidebar keeps the mode of the page underneath
|
|
// instead of flipping to the company nav behind the modal.
|
|
const onSettings = pathname.startsWith('/settings')
|
|
const onCockpitPath = isCockpitPath(pathname)
|
|
// Settings opened from the cockpit carry ?ctx=byra (user menu / mobile
|
|
// nav links, preserved by SettingsRail). It is the signal that SURVIVES a
|
|
// hard refresh, when the previous-surface memory below starts over at
|
|
// false and the sidebar used to flip to the full company nav.
|
|
const ctxByra = useSearchParams().get('ctx') === 'byra'
|
|
// Previous-render memory via the adjust-state-during-render pattern
|
|
// (react.dev: storing information from previous renders); a ref would be
|
|
// simpler but refs must not be read or written during render.
|
|
const [lastNonSettingsCockpit, setLastNonSettingsCockpit] = useState(false)
|
|
if (!onSettings && onCockpitPath !== lastNonSettingsCockpit) {
|
|
setLastNonSettingsCockpit(onCockpitPath)
|
|
}
|
|
const cockpitMode =
|
|
!!byraTeam && (onSettings ? lastNonSettingsCockpit || ctxByra : onCockpitPath)
|
|
// Where "Tillbaka till klienter" points (home-domain rule, WL-01): absolute
|
|
// URL when the byrå cockpit is homed on another host (resolveCockpitHref in
|
|
// the layout), relative '/clients' otherwise. The layout's pre-brand
|
|
// no-company branch leaves cockpitHref unset; there the relative fallback
|
|
// reproduces the pre-cockpitHref link exactly on the cockpit/settings
|
|
// surfaces that branch can render. Cross-host hops render a plain <a>
|
|
// (no client-router prefetch across origins) and carry a "Hanteras via"
|
|
// hint, since the other host will ask for a login (per-host sessions).
|
|
const cockpitHref = byraTeam?.cockpitHref ?? '/clients'
|
|
const cockpitExternal = cockpitHref.startsWith('https://')
|
|
const cockpitHint = cockpitExternal
|
|
? tSwitcher('managed_via', { domain: new URL(cockpitHref).hostname })
|
|
: null
|
|
// Byrå-scope surfaces, not company surfaces: they must stay reachable even
|
|
// when the active company is unresolved.
|
|
const ALWAYS_ENABLED = new Set(['/settings', '/clients', '/byra', '/byra/automations', '/byra/kpi'])
|
|
const isItemEnabled = (href: string) => {
|
|
// The back-to-clients link may be absolute (cross-host cockpit); judge
|
|
// it by its path so it stays as reachable as the relative form.
|
|
const path = href.startsWith('https://') ? new URL(href).pathname : href
|
|
const base = path.split('?')[0]
|
|
return hasCompany || ALWAYS_ENABLED.has(base) || base.startsWith('/settings')
|
|
}
|
|
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 === '/'
|
|
}
|
|
// Cockpit Hem: exact match so /byra/automations and /byra/kpi light up
|
|
// their own rows only.
|
|
if (href === '/byra') {
|
|
return pathname === '/byra'
|
|
}
|
|
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 = (cockpitMode ? cockpitNavItems : 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
|
|
if (item.requiresSalesOrders && !salesOrdersEnabled) return false
|
|
// Webshop surfaces are hidden until a store is connected (or order rows
|
|
// already exist from a since-disconnected store).
|
|
if (item.requiresWebshop && !hasWebshop) return false
|
|
// Körjournal is hidden until the company opts in via the bookkeeping
|
|
// settings toggle (or trips already exist, e.g. created via MCP).
|
|
if (item.requiresMileage && !hasMileage) 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
|
|
// Byrå cockpit: the Klienter entry lives in the lean cockpit sidebar
|
|
// (cockpitNavItems); in company mode the pinned back-to-clients link
|
|
// replaces it, and non-byrå users never see it (WL-14).
|
|
if (item.byraOnly) 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.
|
|
// Extension workspaces are company surfaces: none in cockpit mode.
|
|
const visibleExtensionNavItems = cockpitMode
|
|
? []
|
|
: extensionNavItems.filter((i) => i.href !== '/e/general/tic')
|
|
|
|
const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = cockpitMode
|
|
? []
|
|
: [
|
|
{ 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 }[] = cockpitMode
|
|
? cockpitNavItems.map(({ href, labelKey, icon }) => ({ href, labelKey, icon }))
|
|
: [
|
|
{ href: '/', labelKey: 'home', icon: Home },
|
|
{ href: '/chat', labelKey: 'assistant', icon: Sparkles },
|
|
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight },
|
|
]
|
|
// Same gate as the sidebar: no Assistent tab until the agent is built.
|
|
const mobileNavItems = allMobileNavItems.filter(
|
|
(item) => item.href !== '/chat' || agentIdentity.isVerified,
|
|
)
|
|
|
|
const renderBadge = (item: NavItem | { comingSoon?: boolean; devBadge?: boolean; betaBadge?: boolean }, position: 'sidebar' | 'mobile') => {
|
|
const baseClass =
|
|
position === 'sidebar'
|
|
? 'ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5'
|
|
: 'rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5'
|
|
if (item.comingSoon) return <span className={baseClass}>{tNav('badge_coming_soon')}</span>
|
|
if (item.devBadge) return <span className={baseClass}>{tNav('badge_dev')}</span>
|
|
if (item.betaBadge) return <span className={baseClass}>{tNav('badge_beta')}</span>
|
|
return null
|
|
}
|
|
|
|
const badgeFor = (href: string): number | null =>
|
|
href === '/transactions' && uncategorizedCount > 0
|
|
? uncategorizedCount
|
|
: href === '/pending' && pendingOpsCount > 0
|
|
? pendingOpsCount
|
|
: null
|
|
|
|
const countBubble = (badge: number) => (
|
|
<span data-ph-mask className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
)
|
|
|
|
// One sidebar row (expanded sidebar). Fold children render text-indented
|
|
// without an icon behind the fold's hairline (concept PR 2).
|
|
const renderSidebarItem = (item: NavItem, opts?: { child?: boolean }) => {
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href) && !item.comingSoon
|
|
const badge = badgeFor(item.href)
|
|
const decorBadge = renderBadge(item, 'sidebar')
|
|
const content = (
|
|
<>
|
|
{!opts?.child &&
|
|
renderNavIcon(
|
|
item,
|
|
cn(
|
|
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
|
|
active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground',
|
|
),
|
|
)}
|
|
<span className="flex-1">{tNav(item.labelKey)}</span>
|
|
{decorBadge ? decorBadge : badge !== null && countBubble(badge)}
|
|
</>
|
|
)
|
|
const baseClass = cn(
|
|
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
|
|
enabled
|
|
? cn(
|
|
'transition-colors duration-150',
|
|
active
|
|
? 'bg-secondary text-foreground font-medium'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60',
|
|
)
|
|
: 'text-muted-foreground/40 cursor-not-allowed',
|
|
)
|
|
return enabled ? (
|
|
<NavLink key={item.href} href={item.href} className={baseClass}>
|
|
{content}
|
|
</NavLink>
|
|
) : (
|
|
<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
|
|
/** Cross-origin href: render a plain <a>, no client-router prefetch. */
|
|
external?: boolean
|
|
/** Extra tooltip context (e.g. "Hanteras via {domain}"). */
|
|
hint?: string | null
|
|
},
|
|
) => {
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href) && !item.comingSoon
|
|
const badge = badgeFor(item.href)
|
|
const label = tNav(item.labelKey)
|
|
const title = item.hint ? `${label} · ${item.hint}` : label
|
|
const inner = (
|
|
<span className="relative">
|
|
{renderNavIcon(
|
|
item,
|
|
cn('h-[17px] w-[17px]', active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'),
|
|
)}
|
|
{badge !== null && (
|
|
<span data-ph-mask className="absolute -top-2 -right-2.5 min-w-[15px] h-[15px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
|
|
{badge > 99 ? '99' : badge}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)
|
|
const baseClass = cn(
|
|
'group flex h-9 w-9 items-center justify-center rounded-lg',
|
|
enabled
|
|
? cn('transition-colors duration-150', active ? 'bg-secondary' : 'hover:bg-secondary/60')
|
|
: 'opacity-40 cursor-not-allowed',
|
|
)
|
|
if (enabled && item.external) {
|
|
return (
|
|
<a key={item.href} href={item.href} className={baseClass} title={title} aria-label={title}>
|
|
{inner}
|
|
</a>
|
|
)
|
|
}
|
|
return enabled ? (
|
|
<NavLink key={item.href} href={item.href} className={baseClass} title={title} aria-label={title}>
|
|
{inner}
|
|
</NavLink>
|
|
) : (
|
|
<div key={item.href} className={baseClass} title={title} 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',
|
|
)}
|
|
>
|
|
{/* Brand mark (WL-12 slice A3): brand logo on branded hosts,
|
|
the exact legacy image everywhere else. The expanded sidebar
|
|
also shows the brand domain prefix beside the mark. */}
|
|
<BrandHomeLink showLabel={!collapsed} />
|
|
<button
|
|
type="button"
|
|
onClick={toggleCollapsed}
|
|
aria-label={collapsed ? tNav('expand_nav') : tNav('collapse_nav')}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
|
|
>
|
|
{collapsed ? (
|
|
<PanelLeft className="h-4 w-4" />
|
|
) : (
|
|
<PanelLeftClose className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Nav items in their own scroll container so the user block
|
|
below stays sticky (concept PR 2). */}
|
|
<div className="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden pt-1 pb-2">
|
|
{/* Both states stay mounted and crossfade past each other while
|
|
the aside width animates: no DOM swap, one continuous motion.
|
|
The inactive layer is absolute (no layout), faded, nudged
|
|
sideways and inert. */}
|
|
{/* data-ph-unmask: nav labels are static i18n chrome; count
|
|
bubbles inside carry data-ph-mask (nearest tag wins). */}
|
|
<nav
|
|
data-ph-unmask
|
|
aria-hidden={!collapsed}
|
|
inert={!collapsed ? true : undefined}
|
|
className={cn(
|
|
'flex w-16 flex-col items-center gap-px px-2 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
|
|
collapsed
|
|
? 'opacity-100 translate-x-0'
|
|
: 'pointer-events-none absolute inset-x-0 top-1 opacity-0 -translate-x-3',
|
|
)}
|
|
aria-label={tNav('main_navigation')}
|
|
>
|
|
{/* Rail counterpart of the back-to-clients link. */}
|
|
{byraTeam &&
|
|
!cockpitMode &&
|
|
renderRailItem({
|
|
href: cockpitHref,
|
|
labelKey: 'back_to_clients',
|
|
icon: ArrowLeft,
|
|
external: cockpitExternal,
|
|
hint: cockpitHint,
|
|
})}
|
|
{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 (
|
|
<NavLink
|
|
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',
|
|
)}
|
|
/>
|
|
</NavLink>
|
|
)
|
|
})}
|
|
</nav>
|
|
<nav
|
|
data-ph-unmask
|
|
aria-hidden={collapsed}
|
|
inert={collapsed ? true : undefined}
|
|
className={cn(
|
|
'w-[248px] px-3 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
|
|
collapsed
|
|
? 'pointer-events-none absolute inset-x-0 top-1 opacity-0 translate-x-3'
|
|
: 'opacity-100 translate-x-0',
|
|
)}
|
|
aria-label={tNav('main_navigation')}
|
|
>
|
|
{/* Byrå members inside a company: pinned route back to the
|
|
cockpit, above everything, separated by a hairline. */}
|
|
{byraTeam && !cockpitMode && (
|
|
<div className="mb-2">
|
|
{cockpitExternal ? (
|
|
<a
|
|
href={cockpitHref}
|
|
title={cockpitHint ?? undefined}
|
|
className="group flex items-center px-3 py-[7px] text-[13px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
|
|
>
|
|
<ArrowLeft className="mr-2.5 h-[15px] w-[15px] flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
|
|
<span className="flex-1 min-w-0">
|
|
<span className="block">{tNav('back_to_clients')}</span>
|
|
{/* Visible cross-host hint: title alone never surfaces
|
|
on touch, and the hop lands on the other host's
|
|
login (per-host sessions), so say where it goes. */}
|
|
<span className="block truncate text-[11px] text-muted-foreground">
|
|
{cockpitHint}
|
|
</span>
|
|
</span>
|
|
</a>
|
|
) : (
|
|
<NavLink
|
|
href={cockpitHref}
|
|
className="group flex items-center px-3 py-[7px] text-[13px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
|
|
>
|
|
<ArrowLeft className="mr-2.5 h-[15px] w-[15px] flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
|
|
<span className="flex-1">{tNav('back_to_clients')}</span>
|
|
</NavLink>
|
|
)}
|
|
<div className="mx-3 mt-2 border-t border-border/60" />
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 ? (
|
|
<NavLink key={item.href} href={item.href} className={baseClass}>
|
|
{content}
|
|
</NavLink>
|
|
) : (
|
|
<div
|
|
key={item.href}
|
|
className={baseClass}
|
|
aria-disabled="true"
|
|
title={tNav('needs_company_tooltip')}
|
|
>
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Subscription touchpoint: trial countdown while the trial runs,
|
|
a persistent muted upgrade link once it (or a subscription) has
|
|
lapsed. Hides itself for sandbox/demo and paying companies. */}
|
|
<SubscriptionTouchpoint variant="sidebar" collapsed={collapsed} />
|
|
|
|
{/* 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}
|
|
cockpitMode={cockpitMode}
|
|
onLogout={() => void handleLogout()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Mobile bottom navigation. data-mobile-nav is the brand-style hook:
|
|
on branded hosts the brand style block re-tints the bar's tokens
|
|
(--card/--border/--primary...) to the deep chrome, mirroring the
|
|
sidebar; on default hosts the attribute matches nothing. */}
|
|
<nav data-mobile-nav="" data-ph-unmask className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
|
|
<div className="flex items-center justify-around h-16 px-2">
|
|
{mobileNavItems.map((item) => {
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href)
|
|
const badge = item.href === '/transactions' && uncategorizedCount > 0
|
|
? uncategorizedCount
|
|
: null
|
|
|
|
const content = (
|
|
<>
|
|
<div className="relative">
|
|
{renderNavIcon(item, cn('h-5 w-5 mb-1', active && 'text-primary'))}
|
|
{badge !== null && (
|
|
<span data-ph-mask className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className={cn(
|
|
"truncate",
|
|
active && "font-medium"
|
|
)}>{tNav(item.labelKey)}</span>
|
|
</>
|
|
)
|
|
const baseClass = cn(
|
|
'relative flex flex-col items-center justify-center flex-1 h-full text-xs',
|
|
enabled
|
|
? cn(
|
|
'transition-colors duration-200',
|
|
active ? 'text-primary' : 'text-muted-foreground'
|
|
)
|
|
: 'text-muted-foreground/40'
|
|
)
|
|
|
|
return enabled ? (
|
|
<NavLink key={item.href} href={item.href} className={baseClass}>
|
|
{content}
|
|
</NavLink>
|
|
) : (
|
|
<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-xl 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-xl">
|
|
<div className="w-8 h-1 rounded-full bg-muted-foreground/25" />
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="px-4 pb-2 flex items-center justify-between">
|
|
<div className="flex-1 min-w-0 mr-2">
|
|
<CompanySwitcher />
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 -mr-1"
|
|
onClick={closeMobileMenu}
|
|
aria-label={tNav('close_menu')}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Navigation. data-ph-unmask: static i18n labels only; the
|
|
CompanySwitcher above stays outside so it remains masked,
|
|
and count bubbles inside carry data-ph-mask. */}
|
|
<div data-ph-unmask className="px-2">
|
|
{/* Byrå members inside a company: route back to the cockpit. */}
|
|
{byraTeam && !cockpitMode && (
|
|
<div className="mb-1.5">
|
|
{cockpitExternal ? (
|
|
<a
|
|
href={cockpitHref}
|
|
onClick={closeMobileMenu}
|
|
className="flex items-center gap-3 px-3 min-h-[44px] rounded-lg text-foreground active:bg-muted/60 transition-colors"
|
|
>
|
|
<ArrowLeft className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
|
<span className="flex-1 min-w-0">
|
|
<span className="block text-sm">{tNav('back_to_clients')}</span>
|
|
{/* Visible cross-host hint: touch has no title tooltip. */}
|
|
<span className="block truncate text-[11px] text-muted-foreground">
|
|
{cockpitHint}
|
|
</span>
|
|
</span>
|
|
</a>
|
|
) : (
|
|
<NavLink
|
|
href={cockpitHref}
|
|
onClick={closeMobileMenu}
|
|
className="flex items-center gap-3 px-3 min-h-[44px] rounded-lg text-foreground active:bg-muted/60 transition-colors"
|
|
>
|
|
<ArrowLeft className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
|
<span className="text-sm flex-1">{tNav('back_to_clients')}</span>
|
|
</NavLink>
|
|
)}
|
|
<div className="mx-3 mt-1.5 h-px bg-border/30" />
|
|
</div>
|
|
)}
|
|
{/* Top items (Hem, Assistent) */}
|
|
<div className="space-y-0.5">
|
|
{topItems.map((item) => {
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href)
|
|
const badge: number | null = null
|
|
const decorBadge = renderBadge(item, 'mobile')
|
|
const content = (
|
|
<>
|
|
{renderNavIcon(item, cn('h-[18px] w-[18px] flex-shrink-0', active ? 'text-primary' : 'text-muted-foreground'))}
|
|
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
|
|
{decorBadge ? decorBadge : badge !== null && (
|
|
<span data-ph-mask className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
)}
|
|
</>
|
|
)
|
|
const baseClass = cn(
|
|
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
|
|
enabled
|
|
? cn(
|
|
'transition-colors',
|
|
active
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
: 'text-foreground active:bg-muted/60'
|
|
)
|
|
: 'text-muted-foreground/40'
|
|
)
|
|
return enabled ? (
|
|
<NavLink
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={closeMobileMenu}
|
|
className={baseClass}
|
|
>
|
|
{content}
|
|
</NavLink>
|
|
) : (
|
|
<div key={item.href} className={baseClass} aria-disabled="true">
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Arbeta / Analys / Data / Skatt & bokslut groups (mobile) */}
|
|
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items }) => (
|
|
<div key={key}>
|
|
<div className="flex items-center gap-3 my-1.5 px-3">
|
|
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav(groupLabelKey[key])}</span>
|
|
<div className="flex-1 h-px bg-border/30" />
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{items.map((item) => {
|
|
const Icon = item.icon
|
|
const active = isActive(item.href)
|
|
const enabled = isItemEnabled(item.href) && !item.comingSoon
|
|
const badge = item.href === '/transactions' && uncategorizedCount > 0
|
|
? uncategorizedCount
|
|
: item.href === '/pending' && pendingOpsCount > 0
|
|
? pendingOpsCount
|
|
: null
|
|
const decorBadge = renderBadge(item, 'mobile')
|
|
const content = (
|
|
<>
|
|
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
|
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
|
|
{decorBadge ? decorBadge : badge !== null && (
|
|
<span data-ph-mask className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
)}
|
|
</>
|
|
)
|
|
const baseClass = cn(
|
|
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
|
|
enabled
|
|
? cn(
|
|
'transition-colors',
|
|
active
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
: 'text-foreground active:bg-muted/60'
|
|
)
|
|
: 'text-muted-foreground/40'
|
|
)
|
|
return enabled ? (
|
|
<NavLink
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={closeMobileMenu}
|
|
className={baseClass}
|
|
>
|
|
{content}
|
|
</NavLink>
|
|
) : (
|
|
<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 ? (
|
|
<NavLink
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={closeMobileMenu}
|
|
className={baseClass}
|
|
>
|
|
{content}
|
|
</NavLink>
|
|
) : (
|
|
<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">
|
|
{/* Subscription touchpoint: mobile had no trial surface at
|
|
all before this row (trial countdown or lapsed upgrade
|
|
link; hides itself for sandbox and paying companies). */}
|
|
<SubscriptionTouchpoint variant="mobile" onNavigate={closeMobileMenu} />
|
|
{([
|
|
// Cockpit: settings open in byrå scope (account-level
|
|
// sections only), same as the desktop user menu.
|
|
{ href: cockpitMode ? '/settings/account?ctx=byra' : '/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 ? (
|
|
<NavLink
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={closeMobileMenu}
|
|
className={baseClass}
|
|
>
|
|
{content}
|
|
</NavLink>
|
|
) : (
|
|
<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>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|