feat(nav): concept sidebar with folds, collapse rail, and user menu (UI migration PR 2) (#1133)

* feat(nav): concept sidebar with folds, collapse rail, and user menu (UI migration PR 2)

The concept's navigation, exactly, with all current functionality kept:

- Groups restructured per concept: top (Hem, Assistenten), ARBETA
  (Bokforing, Underlag, Transaktioner, Granskning, Kundfakturor,
  Leverantorsfakturor, Loner), ANALYS, DATA (Register fold +
  Importera/exportera), SKATT & BOKSLUT (Moms, Skattekonto, Viktiga
  datum, Bokslut fold). Entity/capability/dimension gating unchanged.
- Register and Bokslut are animated folds (grid-rows 0fr/1fr), children
  text-indented behind a hairline; closed by default, forced open by an
  active child route; state persists per user.
- Sidebar collapses to a 64px icon rail (toggle top of rail); width is
  one inline --nav-w CSS variable on #dash-shell that aside and <main>
  both read, so the panel follows in lockstep. Server-rendered from
  ui_state so first paint is right.
- Sticky bottom user block (avatar, name, active company) opening an
  upward user menu: identity, company-switcher flyout (search + building
  glyphs + roles + check, real switch mechanism via shared
  lib/company/switch-client), Installningar, Medlemmar och roller,
  Abonnemang, Hjalp, support, terracotta logout. Trial touchpoint kept.
- CompanySwitcher removed from desktop top (lives in the user menu now);
  mobile bottom nav + sheet unchanged.
- New migration 20260723120000: user_preferences.ui_state jsonb bag
  (founder-approved) + POST /api/user/ui-state (requireAuth, strict zod,
  merge semantics) with route tests.
- i18n: fold/collapse/menu keys added sv+en; deadlines -> "Viktiga
  datum", year_end -> "Arsbokslut" per concept.

Discord-community row deferred: no invite URL exists in the repo.
Badges stay the current two (Transaktioner, Granskning); an Underlag
count is a follow-up with lib/worklist.

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

* fix(nav): brand-mark sidebar header + auto-hiding scrollbars

Concept alignment feedback: the sidebar gets a header row (brand mark
left, collapse toggle right) hanging from the same top line as the
panel, instead of a lone right-aligned toggle.

Scrollbars go overlay-style app-wide: transparent at rest, revealed only
while their container scrolls (ScrollbarReveal stamps .is-scrolling via
one capture-phase document listener), fading out after 700ms idle. The
gutter stays reserved so revealing never shifts layout.

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

* fix(nav): company flyout opens downward + Discord community row

The flyout was bottom-anchored to its row and grew upward over the menu;
founder feedback: top-align with the row and grow downward. Adds the
Discord community row to the user menu (external invite link).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-23 21:28:07 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6b506a9ca8
commit d59e4708cf
15 changed files with 1137 additions and 385 deletions
+1
View File
@@ -337,3 +337,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-22] Förening chart: hand-maintain from free BAS 2026 PDF, ship Idrottens BAS as a separate optional template (not merged): BAS machine-readable JSON is a paid proprietary product unfit for AGPL vendoring, and IBK 38xx/35xx collide with standard BAS meanings.
[2026-07-23] UI-migration plan authored (dev_docs/ui_migration_plan.md): shell-first sequence (tokens/nav/primitives) then Bokforing/Transaktioner/Granskning/Kund+Lev-fakturor; concept artifact is the reference; 14 locked design conventions codified: chose PR-per-page over big-bang to keep founder visual sign-off per merge.
[2026-07-23] Frame layout (UI-migration PR 1) is md:-gated and the panel owns desktop scroll: mobile keeps document flow + bottom nav (concept is desktop-first), and since Next's window scroll-to-top never fires for an inner scroll container, MainContainer resets panel scroll on route change. Button default size drops fixed h-10 for natural pill height (7px/16px padding per locked convention 3); sm/lg/icon keep their heights.
[2026-07-23] Nav PR 2: ui_state persisted as one jsonb bag on user_preferences (founder-approved migration 20260723120000) rather than per-preference columns: cosmetic, never load-bearing, grows with PR 3/4 split-button modes. Sidebar width driven by an inline --nav-w CSS variable on #dash-shell instead of a [data-nav-collapsed] attribute rule: the Tailwind 4/Lightning CSS pipeline silently dropped the top-level attribute-selector rule from compiled output, and the inline variable is pipeline-proof. Register/Bokslut folds default closed (concept tidiness), forced open by an active child route. Discord-community row skipped: no invite URL exists in the repo; add when one lands.
+19 -3
View File
@@ -38,8 +38,9 @@ const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
*/
const MAIN_PANEL_CLASS =
'safe-area-main-padding md:!pb-0 relative bg-background min-h-screen ' +
'md:min-h-0 md:ml-64 md:mt-[10px] md:mr-[10px] md:h-[calc(100vh-20px)] ' +
'md:overflow-y-auto md:rounded-xl md:border md:border-border'
'md:min-h-0 md:ml-[var(--nav-w)] md:mt-[10px] md:mr-[10px] md:h-[calc(100vh-20px)] ' +
'md:overflow-y-auto md:rounded-xl md:border md:border-border ' +
'md:transition-[margin-left] md:duration-200'
export default async function DashboardLayout({
children,
@@ -146,6 +147,7 @@ export default async function DashboardLayout({
{ data: userProfile },
entitlements,
{ data: allSettingsNames },
{ data: userPrefs },
] = await Promise.all([
supabase.from('companies').select('*').eq('id', companyId).single(),
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
@@ -168,6 +170,9 @@ export default async function DashboardLayout({
// select returns exactly the caller's companies, letting non-active rows
// show company_settings.company_name instead of the frozen companies.name.
supabase.from('company_settings').select('company_id, company_name'),
// Per-user UI state (nav collapse/fold state): server-rendered so the
// sidebar width is right on first paint, no post-hydration jump.
supabase.from('user_preferences').select('ui_state').eq('user_id', user.id).maybeSingle(),
])
// company_id -> current display name for every company the user belongs to.
@@ -248,6 +253,12 @@ export default async function DashboardLayout({
const isSandbox = settings?.is_sandbox === true
// Client-driven UI preferences (sidebar collapse + fold state). Read here
// so the shell renders at the right width on first paint; the nav toggles
// flip the data attribute client-side and persist via /api/user/ui-state.
const uiState = (userPrefs?.ui_state ?? {}) as import('@/types').UserUiState
const navCollapsed = uiState.nav_collapsed === true
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
@@ -278,7 +289,11 @@ export default async function DashboardLayout({
}}
>
<CompanyTabSync />
<div className="min-h-screen bg-frame md:flex md:flex-col">
<div
id="dash-shell"
className="min-h-screen bg-frame md:flex md:flex-col"
style={{ '--nav-w': navCollapsed ? '64px' : '248px' } as React.CSSProperties}
>
{/* Skip to content link for keyboard/screen reader users */}
<a
href="#main-content"
@@ -296,6 +311,7 @@ export default async function DashboardLayout({
extensionNavItems={getExtensionNavItems()}
userName={userProfile?.full_name ?? null}
userEmail={user.email ?? null}
initialUiState={uiState}
/>
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
<MainContainer companyId={companyId}>{children}</MainContainer>
@@ -0,0 +1,88 @@
/**
* Tests for POST /api/user/ui-state: the per-user UI preference bag
* (nav collapse/fold state, split-button create modes).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
import { POST } from '../route'
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
})
function request(body: unknown) {
return createMockRequest('/api/user/ui-state', { method: 'POST', body })
}
describe('POST /api/user/ui-state', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(request({ nav_collapsed: true }))
expect(res.status).toBe(401)
})
it('returns 400 on unknown keys (strict schema)', async () => {
const res = await POST(request({ nav_collapsed: true, evil: 'x' }))
expect(res.status).toBe(400)
})
it('returns 400 on wrong value types', async () => {
const res = await POST(request({ nav_collapsed: 'yes' }))
expect(res.status).toBe(400)
})
it('merges the patch into the existing ui_state', async () => {
// select existing row
enqueue({
data: { ui_state: { nav_collapsed: false, nav_folds: { register: true } } },
})
// upsert result
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{
data: { ui_state: { nav_collapsed: boolean; nav_folds: Record<string, boolean> } }
}>(await POST(request({ nav_folds: { bokslut: true } })))
expect(status).toBe(200)
expect(body.data.ui_state).toEqual({
nav_collapsed: false,
nav_folds: { register: true, bokslut: true },
})
})
it('handles a missing preferences row (first write)', async () => {
enqueue({ data: null }) // no existing row
enqueue({ data: null }) // upsert
const { status, body } = await parseJsonResponse<{
data: { ui_state: { nav_collapsed: boolean } }
}>(await POST(request({ nav_collapsed: true })))
expect(status).toBe(200)
expect(body.data.ui_state).toEqual({ nav_collapsed: true })
})
it('returns 500 when the upsert fails', async () => {
enqueue({ data: null })
enqueue({ data: null, error: { message: 'boom' } })
const res = await POST(request({ nav_collapsed: true }))
expect(res.status).toBe(500)
})
})
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireAuth } from '@/lib/auth/require-auth'
import type { UserUiState } from '@/types'
// Partial update: the client sends only the keys it changed. Strict schemas
// so typos fail loudly instead of accumulating junk in the jsonb bag.
const BodySchema = z
.object({
nav_collapsed: z.boolean().optional(),
nav_folds: z
.object({
register: z.boolean().optional(),
bokslut: z.boolean().optional(),
})
.strict()
.optional(),
create_mode: z.record(z.string(), z.string().max(64)).optional(),
})
.strict()
// User-scoped preference endpoint: no company context exists or is needed,
// so requireAuth() directly (same opt-out as /api/user/locale). RLS scopes
// user_preferences to the caller's own row.
export async function POST(request: Request) {
const { user, supabase, error } = await requireAuth()
if (error) return error
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const parsed = BodySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid ui_state payload' }, { status: 400 })
}
// Read-merge-write: last write wins per key. Fine for cosmetic UI state;
// concurrent tabs converge on the next read.
const { data: existing } = await supabase
.from('user_preferences')
.select('ui_state')
.eq('user_id', user.id)
.maybeSingle()
const current: UserUiState = (existing?.ui_state as UserUiState) ?? {}
const patch = parsed.data
const next: UserUiState = {
...current,
...patch,
...(patch.nav_folds
? { nav_folds: { ...current.nav_folds, ...patch.nav_folds } }
: {}),
...(patch.create_mode
? { create_mode: { ...current.create_mode, ...patch.create_mode } }
: {}),
}
const { error: upsertError } = await supabase
.from('user_preferences')
.upsert({ user_id: user.id, ui_state: next }, { onConflict: 'user_id' })
if (upsertError) {
return NextResponse.json({ error: 'Could not save UI preferences' }, { status: 500 })
}
return NextResponse.json({ data: { ui_state: next } })
}
+28 -4
View File
@@ -11,6 +11,14 @@
:root {
color-scheme: light;
/* Sidebar column width (frame layout). Default for shells that don't
override it; the dashboard shell (#dash-shell) sets it inline from
user_preferences.ui_state (248px expanded / 64px collapsed rail) and
the nav toggle updates it client-side. aside and <main> both read the
variable so they stay in lockstep. */
--nav-w: 248px;
/* Grayscale Chrome Palette: paper white + warm beige */
--background: 0 0% 100%;
@@ -169,6 +177,7 @@
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
@@ -322,7 +331,11 @@ h1, h2, h3 {
0 0 0 4px hsl(var(--primary) / 0.35);
}
/* Custom scrollbar - minimal */
/* Overlay-style scrollbars: invisible at rest, revealed only while the
container is actually scrolling. ScrollbarReveal (mounted in the root
layout) toggles .is-scrolling on the scrolled element; the thumb fades
back out when scrolling stops. The gutter is reserved either way, so
revealing never shifts layout. */
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -333,12 +346,23 @@ h1, h2, h3 {
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.2);
background: transparent;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.3);
.is-scrolling::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.25);
}
@supports (scrollbar-color: auto) {
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 300ms ease-out;
}
.is-scrolling {
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
}
/* Hide scrollbar utility */
+2
View File
@@ -11,6 +11,7 @@ import { ThemeProvider } from "@/components/theme-provider";
import { SWRProvider } from "@/components/providers/SWRProvider";
import { RecaptLoader } from "@/components/RecaptLoader";
import { RecaptHideWidget } from "@/components/RecaptHideWidget";
import { ScrollbarReveal } from "@/components/ScrollbarReveal";
import { ensureInitialized } from "@/lib/init";
import { getBranding } from "@/lib/branding/service";
import "./globals.css";
@@ -92,6 +93,7 @@ export default async function RootLayout({
<Toaster />
<DeployReloadPrompt />
<RecaptHideWidget />
<ScrollbarReveal />
</SWRProvider>
</ThemeProvider>
</NextIntlClientProvider>
+42
View File
@@ -0,0 +1,42 @@
'use client'
import { useEffect } from 'react'
// How long after the last scroll event the scrollbar stays visible.
const IDLE_MS = 700
/**
* Reveals scrollbars only while their container is actually scrolling.
* One capture-phase listener on the document catches scroll events from
* every scroll container (panel, sidebar, dialogs, dropdown lists) and
* stamps .is-scrolling on the scrolled element; globals.css makes the
* thumb visible for that class and transparent otherwise. Renders nothing.
*/
export function ScrollbarReveal() {
useEffect(() => {
const timers = new WeakMap<Element, number>()
const onScroll = (e: Event) => {
const target = e.target
const el =
target === document ? document.documentElement : (target as Element)
if (!(el instanceof Element)) return
el.classList.add('is-scrolling')
const prev = timers.get(el)
if (prev) window.clearTimeout(prev)
timers.set(
el,
window.setTimeout(() => el.classList.remove('is-scrolling'), IDLE_MS),
)
}
document.addEventListener('scroll', onScroll, {
capture: true,
passive: true,
})
return () =>
document.removeEventListener('scroll', onScroll, { capture: true })
}, [])
return null
}
+5 -22
View File
@@ -6,7 +6,7 @@ import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { switchCompany } from '@/lib/company/actions'
import { performCompanySwitch } from '@/lib/company/switch-client'
import { useToast } from '@/components/ui/use-toast'
import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
@@ -81,33 +81,16 @@ export default function CompanySwitcher() {
return
}
setIsPending(true)
const result = await switchCompany(companyId)
if (result.error) {
// Shared switch-and-reload mechanism (lib/company/switch-client), same
// path as the sidebar user-menu flyout.
const result = await performCompanySwitch(companyId)
if (result?.error) {
setIsPending(false)
toast({
title: t(result.error === 'not_member' ? 'error_no_access' : 'error_switch_failed'),
variant: 'destructive',
})
return
}
// Notify every other open tab of the same user so they hard-reload
// onto the new company. BroadcastChannel is best-effort: if the
// browser doesn't support it (very old) we still hard-reload
// ourselves, and other tabs will self-correct via the visibilitychange
// / pageshow listeners in CompanyTabSync on their next focus event.
if (typeof BroadcastChannel !== 'undefined') {
try {
const channel = new BroadcastChannel('gnubok-company-switch')
channel.postMessage({ companyId })
channel.close()
} catch {
// Ignore: hard reload still happens below
}
}
// Hard navigation: tears down React state, router cache, in-flight
// fetches, blob URLs, etc. This is the whole point: nothing from the
// previous company can survive the switch.
window.location.assign('/')
}
// Always allow opening the dropdown (to show "Lägg till företag")
+427 -348
View File
@@ -2,6 +2,7 @@
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'
@@ -22,7 +23,6 @@ import {
Menu,
X,
HelpCircle,
ChevronDown,
Building2,
Wallet,
TrendingUp,
@@ -32,7 +32,6 @@ import {
Tag,
Tags,
ChevronRight,
ChevronsUpDown,
Clock,
Sparkles,
Percent,
@@ -42,28 +41,25 @@ import {
FileCheck,
FileSpreadsheet,
ScrollText,
PanelLeft,
PanelLeftClose,
Library,
BookCheck,
} from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { clearRecaptIdentity } from '@/lib/recapt'
import { SupportLink } from '@/components/ui/support-link'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
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 { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys'
import type { EntityType } from '@/types'
import type { EntityType, UserUiState } from '@/types'
void _ENABLED_EXTENSION_IDS
@@ -91,6 +87,9 @@ interface DashboardNavProps {
// 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 =
@@ -125,28 +124,34 @@ type NavLabelKey =
| 'help'
| 'settings'
// Nav layout (July 2026, interaction-mode grouping, dev_docs/nav_ia_redesign.md
// Phase 0): same routes as before, regrouped by what the user is doing.
// top-of-sidebar : CompanySwitcher (active company / org context).
// top section : flat, no dropdown: Hem, Assistent. Hem doubles as
// the "what needs me" surface until a dedicated
// /inbox exists.
// four dropdowns : Arbeta (produce: the bookkeeping funnel first,
// then invoices, supplier invoices, payroll),
// Analys (KPI + reports), Data (master-data
// registers + import/export), Skatt & bokslut
// (statutory: VAT, tax account, deadlines, year-end).
// bottom-left popover : signed-in user's name + initial, opens upward
// to Inställningar, Hjälp, Support, Logga ut.
// Help + Settings are NOT in `navItems`; they live in the account popover.
// 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
@@ -168,16 +173,17 @@ interface NavItem {
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
// 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. The bookkeeping funnel leads
// (Bokföring · Underlag · Transaktioner · Granskning: kept as separate
// rows until the unified workspace lands), then the transactional flows.
// employerOnly: shown to aktiebolag and to any employer (pays_salaries), so an
// enskild firma that hires staff gets payroll. Owner self-payroll stays
// blocked at the engine/DB layer (EF owner takes egna uttag, not lön). #782
// 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' },
@@ -185,40 +191,64 @@ const navItems: NavItem[] = [
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true },
// Analys: read the numbers. KPI stays a separate row until the fused
// Rapporter surface (nav_ia_redesign §F) is built.
// Analys: read the numbers.
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' },
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' },
// "Vad din agent vet" now lives inside Settings (Assistenten -> Kunskap,
// the default view), reachable via /settings/assistant, not the top nav.
// Data: master-data registers + data plumbing. Anställda is a register
// (you edit an employee rarely, you run payroll monthly), so it lives here
// while the Löner flow stays in Arbeta.
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'data' },
{ href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'data' },
{ href: '/articles', labelKey: 'articles', icon: Tag, group: 'data' },
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'data', employerOnly: true },
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'data' },
{ href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'data' },
{ href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'data', requiresDimensions: true },
// 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. Rescues the
// previously nav-orphaned /skattekonto and /deadlines, and promotes the
// VAT declaration out of the report catalog. The year-end chain
// (periodiseringar → bokslut → årsredovisning → inkomstdeklaration) is
// listed in workflow order; the last two are entity-gated because the
// surfaces only exist for one company form (ÅR + INK2 for aktiebolag,
// NE-bilaga for enskild firma).
// 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' },
{ href: '/bookkeeping/year-end', labelKey: 'year_end', icon: FileCheck, group: 'skatt' },
{ href: '/bookkeeping/year-end/arsredovisning', labelKey: 'annual_report', icon: ScrollText, group: 'skatt', entityOnly: 'aktiebolag' },
{ href: '/reports/ink2-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', entityOnly: 'aktiebolag' },
{ href: '/reports/ne-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', entityOnly: 'enskild_firma' },
{ 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 {
@@ -234,19 +264,7 @@ const groupLabelKey: Record<Exclude<GroupKey, 'top'>, string> = {
skatt: 'group_tax',
}
// Best single-character initial we can show in the bottom-left account
// trigger. Prefers the first letter of the user's full name; falls back
// to the email's first character; falls back to "?" so the avatar never
// renders empty.
function accountInitial(name: string | null, email: string | null): string {
const trimmedName = name?.trim()
if (trimmedName && trimmedName.length > 0) return trimmedName[0]!.toUpperCase()
const trimmedEmail = email?.trim()
if (trimmedEmail && trimmedEmail.length > 0) return trimmedEmail[0]!.toUpperCase()
return '?'
}
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
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()
@@ -294,21 +312,48 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const hasCompany = !!company
const ALWAYS_ENABLED = new Set(['/settings'])
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
// Per-group collapse state. Default: ALL groups expanded. The user
// can see every child link without hunting. Clicking the chevron
// collapses the group; opening it again restores the children.
// Active route still forces a group expanded even when the user has
// manually collapsed it (so deep-linking into /salary doesn't leave
// Personal hidden).
type ExpandableGroup = Exclude<GroupKey, 'top'>
const [manualCollapsed, setManualCollapsed] = useState<Record<ExpandableGroup, boolean>>({
arbeta: false,
analys: false,
data: false,
skatt: false,
// Persist a partial ui_state patch. Fire-and-forget: this is cosmetic
// preference data; a lost write self-corrects on the next toggle.
const persistUiState = (patch: Partial<UserUiState>) => {
void fetch('/api/user/ui-state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).catch(() => {})
}
// 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 toggleGroup = (g: ExpandableGroup) =>
setManualCollapsed((prev) => ({ ...prev, [g]: !prev[g] }))
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) {
@@ -479,11 +524,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{ key: 'skatt', items: filteredItems.filter((i) => i.group === 'skatt') },
]
// A group is expanded when the user hasn't manually collapsed it OR
// an active route lives inside it (the active route always wins so a
// deep-link to /salary keeps Personal open even if previously collapsed).
const isGroupExpanded = (g: ExpandableGroup, items: NavItem[]) =>
!manualCollapsed[g] || items.some((it) => isActive(it.href))
// 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 },
@@ -506,209 +549,298 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
return null
}
const badgeFor = (href: string): number | null =>
href === '/transactions' && uncategorizedCount > 0
? uncategorizedCount
: href === '/pending' && pendingOpsCount > 0
? pendingOpsCount
: null
const countBubble = (badge: number) => (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
)
// One sidebar row (expanded sidebar). Fold children render text-indented
// without an icon behind the fold's hairline (concept PR 2).
const renderSidebarItem = (item: NavItem, opts?: { child?: boolean }) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = badgeFor(item.href)
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
{!opts?.child &&
renderNavIcon(
item,
cn(
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground',
),
)}
<span className="flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && countBubble(badge)}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60',
)
: 'text-muted-foreground/40 cursor-not-allowed',
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={item.comingSoon ? tNav('badge_coming_soon') : tNav('needs_company_tooltip')}
>
{content}
</div>
)
}
// Fold block: header row + grid-rows 0fr/1fr height animation, children
// indented behind a hairline left edge.
const renderFold = (segKey: FoldKey, items: NavItem[]) => {
const open = isFoldOpen(segKey, items)
const cfg = foldConfig[segKey]
const FoldIcon = cfg.icon
return (
<div key={segKey}>
<button
type="button"
onClick={() => toggleFold(segKey)}
aria-expanded={open}
className="group flex w-full items-center px-3 py-[7px] text-[13px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
>
<FoldIcon className="mr-2.5 h-[15px] w-[15px] flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
<span className="flex-1 text-left">{tNav(cfg.labelKey)}</span>
<ChevronRight
className={cn('h-3 w-3 transition-transform duration-200', open && 'rotate-90')}
/>
</button>
<div
className="grid transition-[grid-template-rows] duration-200 ease-out"
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
<div className="ml-[19px] border-l border-border pl-1.5 py-px space-y-px">
{items.map((item) => renderSidebarItem(item, { child: true }))}
</div>
</div>
</div>
</div>
)
}
// Collapsed 64px rail: icon-only rows, native title tooltips, count
// bubbles pinned to the icon corner.
const renderRailItem = (
item: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard; comingSoon?: boolean },
) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = badgeFor(item.href)
const label = tNav(item.labelKey)
const inner = (
<span className="relative">
{renderNavIcon(
item,
cn('h-[17px] w-[17px]', active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'),
)}
{badge !== null && (
<span className="absolute -top-2 -right-2.5 min-w-[15px] h-[15px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
{badge > 99 ? '99' : badge}
</span>
)}
</span>
)
const baseClass = cn(
'group flex h-9 w-9 items-center justify-center rounded-lg',
enabled
? cn('transition-colors duration-150', active ? 'bg-secondary' : 'hover:bg-secondary/60')
: 'opacity-40 cursor-not-allowed',
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass} title={label} aria-label={label}>
{inner}
</Link>
) : (
<div key={item.href} className={baseClass} title={label} aria-disabled="true">
{inner}
</div>
)
}
return (
<>
{/* Desktop sidebar */}
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-64 md:flex-col">
<aside className="hidden md:fixed md:inset-y-0 md:z-10 md:flex md:w-[var(--nav-w)] md:flex-col md:transition-[width] md:duration-200">
{/* Borderless on the frame: the panel next to it carries the border */}
<div className="flex min-h-0 flex-1 flex-col bg-transparent">
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
{/* Company switcher pinned to the top: the active company is
the strongest piece of context for everything below it. */}
<div className="px-5 mb-8">
<CompanySwitcher />
</div>
{/* 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="rounded-md"
/>
</Link>
<button
type="button"
onClick={toggleCollapsed}
aria-label={collapsed ? tNav('expand_nav') : tNav('collapse_nav')}
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
>
{collapsed ? (
<PanelLeft className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>
</div>
{/* Nav items in their own scroll container so the user block
below stays sticky (concept PR 2). */}
<div className="flex-1 min-h-0 overflow-y-auto pt-1 pb-2">
{collapsed ? (
<nav
className="flex flex-col items-center gap-px px-2"
aria-label={tNav('main_navigation')}
>
{railItems.map((item) => renderRailItem(item))}
{visibleExtensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const labelTranslationKey = extensionLabelKey(item.href)
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
title={label}
aria-label={label}
className={cn(
'group flex h-9 w-9 items-center justify-center rounded-lg transition-colors duration-150',
active ? 'bg-secondary' : 'hover:bg-secondary/60',
)}
>
<Icon
className={cn(
'h-[17px] w-[17px]',
active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground',
)}
/>
</Link>
)
})}
</nav>
) : (
<nav className="px-3" aria-label={tNav('main_navigation')}>
{/* Top section: flat, no header. Hem, Assistent. */}
<div className="mb-4 space-y-px">
{topItems.map((item) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge: number | null = null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
{renderNavIcon(
item,
cn(
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground',
),
)}
<span className="flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
)}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60',
)
: 'text-muted-foreground/40 cursor-not-allowed',
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
)
})}
{topItems.map((item) => renderSidebarItem(item))}
</div>
{/* Collapsible groups: Arbeta, Analys, Data, Skatt & bokslut */}
{/* Groups: Arbeta, Analys, Data, Skatt & bokslut. Static
headers; only the Register/Bokslut folds collapse. */}
{sidebarGroups
.filter(({ items }) => items.length > 0)
.map(({ key, items }) => {
const expanded = isGroupExpanded(key, items)
return (
<div key={key} className="mb-1">
<button
onClick={() => toggleGroup(key)}
className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] hover:text-foreground transition-colors rounded-lg"
>
<span>{tNav(groupLabelKey[key])}</span>
<ChevronDown
className={cn(
'h-3 w-3 transition-transform duration-200',
expanded && 'rotate-180',
)}
/>
</button>
{expanded && (
<div className="space-y-px animate-fade-in mb-2">
{items.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge =
item.href === '/transactions' && uncategorizedCount > 0
? uncategorizedCount
: item.href === '/pending' && pendingOpsCount > 0
? pendingOpsCount
: null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
<Icon
className={cn(
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
active
? 'text-primary'
: 'text-muted-foreground group-hover:text-foreground',
)}
/>
<span className="flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
)}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60',
)
: 'text-muted-foreground/40 cursor-not-allowed',
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={
item.comingSoon
? tNav('badge_coming_soon')
: tNav('needs_company_tooltip')
}
>
{content}
</div>
)
})}
{/* Extension nav items land in Arbeta since extension
workspaces are work surfaces. Future categorised
extensions can opt into a different group via
their manifest. */}
{key === 'arbeta' &&
visibleExtensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
const enabled = hasCompany
const labelTranslationKey = extensionLabelKey(item.href)
const label = labelTranslationKey
? tNav(labelTranslationKey)
: item.label
const content = (
<>
<Icon
className={cn(
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
active
? 'text-primary'
: 'text-muted-foreground group-hover:text-foreground',
)}
/>
{label}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60',
)
: 'text-muted-foreground/40 cursor-not-allowed',
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
)
})}
</div>
)}
.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
@@ -716,7 +848,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
chrome instead of only inside Inställningar → Abonnemang.
Hidden for sandbox/demo (no checkout) and once any non-trial
grant is active (trialEndsAt is null then). */}
{!isSandbox && trialDaysLeft !== null && (
{!collapsed && !isSandbox && trialDaysLeft !== null && (
<div className="flex-shrink-0 px-3 pb-2">
<Link
href="/settings/billing"
@@ -731,75 +863,22 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</div>
)}
{/* Account popover (bottom-left). Triggered by the signed-in
user's name + initial. Holds Inställningar, Hjälp, Support,
Logga ut. CompanySwitcher lives at the top of the sidebar,
not in here, different concept ("which company am I working
with" vs "who am I logged in as"). */}
<div className="flex-shrink-0 px-3 py-3 border-t border-border">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors duration-150"
>
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-secondary text-[11px] font-semibold uppercase text-foreground">
{accountInitial(userName, userEmail)}
</span>
<span className="flex-1 truncate font-medium text-foreground">
{userName?.trim() || userEmail || tNav('mitt_konto')}
</span>
<ChevronsUpDown className="h-3.5 w-3.5 opacity-50 group-hover:opacity-100" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-60">
{(userName || userEmail) && (
<>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-0.5">
{userName && (
<span className="text-sm font-medium text-foreground truncate">
{userName}
</span>
)}
{userEmail && (
<span className="text-xs text-muted-foreground truncate">
{userEmail}
</span>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href="/settings" className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
{tNav('settings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/help" className="cursor-pointer">
<HelpCircle className="mr-2 h-4 w-4" />
{tNav('help')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<SupportLink variant="muted" className="cursor-pointer" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
void handleLogout()
}}
className="cursor-pointer text-muted-foreground focus:text-foreground"
>
<LogOut className="mr-2 h-4 w-4" />
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Sticky user block (bottom-left): avatar, name, active company.
Opens the upward user menu with the company-switcher flyout,
account links and logout (concept PR 2). */}
<div
className={cn(
'flex-shrink-0 border-t border-border/60',
collapsed ? 'px-2 py-2' : 'px-3 py-2',
)}
>
<UserMenu
userName={userName}
userEmail={userEmail}
isSandbox={isSandbox}
collapsed={collapsed}
onLogout={() => void handleLogout()}
/>
</div>
</div>
</aside>
+358
View File
@@ -0,0 +1,358 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { performCompanySwitch } from '@/lib/company/switch-client'
import { useToast } from '@/components/ui/use-toast'
import { SupportLink } from '@/components/ui/support-link'
import {
Building2,
Check,
ChevronsUpDown,
ChevronRight,
CreditCard,
HelpCircle,
Loader2,
LogOut,
MessagesSquare,
Plus,
Search,
Settings,
Users,
} from 'lucide-react'
// Community invite (Accounted's Discord). Deliberately a constant, not
// branding config: self-hosted rebrands can hide or swap it when someone
// actually asks for that.
const DISCORD_INVITE_URL = 'https://discord.gg/D9SxtTgvx'
interface UserMenuProps {
userName: string | null
userEmail: string | null
isSandbox: boolean
collapsed: boolean
onLogout: () => void
}
// Best single-character initial for the avatar. Prefers the first letter of
// the full name; falls back to the email; falls back to "?" so the circle
// never renders empty.
function accountInitial(name: string | null, email: string | null): string {
const trimmedName = name?.trim()
if (trimmedName && trimmedName.length > 0) return trimmedName[0]!.toUpperCase()
const trimmedEmail = email?.trim()
if (trimmedEmail && trimmedEmail.length > 0) return trimmedEmail[0]!.toUpperCase()
return '?'
}
/**
* Sticky bottom-of-sidebar user block: avatar initials, name, active company,
* chevron. Opens an upward popover aligned with the nav column holding
* identity, the company-switcher flyout, account links and logout.
* Concept reference: ui_migration_plan.md PR 2.
*/
export default function UserMenu({
userName,
userEmail,
isSandbox,
collapsed,
onLogout,
}: UserMenuProps) {
const { company, companies, isSandbox: companyCtxSandbox } = useCompany()
const tNav = useTranslations('nav')
const tCommon = useTranslations('common')
const tSwitcher = useTranslations('company_switcher')
const { toast } = useToast()
const [open, setOpen] = useState(false)
const [companiesOpen, setCompaniesOpen] = useState(false)
const [query, setQuery] = useState('')
const [isPending, setIsPending] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const [menuPos, setMenuPos] = useState({ top: 0, left: 0 })
const sandbox = isSandbox || companyCtxSandbox
const updatePosition = useCallback(() => {
if (!triggerRef.current || !menuRef.current) return
const triggerRect = triggerRef.current.getBoundingClientRect()
const menuRect = menuRef.current.getBoundingClientRect()
const margin = 8
// Upward popover: bottom edge sits just above the trigger, left-aligned
// with the nav column.
const top = Math.max(margin, triggerRect.top - menuRect.height - 6)
const left = Math.max(margin, triggerRect.left)
setMenuPos({ top, left })
}, [])
useEffect(() => {
if (!open) return
const raf = requestAnimationFrame(() => updatePosition())
return () => cancelAnimationFrame(raf)
}, [open, companiesOpen, updatePosition])
// Focus the search field when the company flyout opens.
useEffect(() => {
if (!companiesOpen) return
const raf = requestAnimationFrame(() => searchRef.current?.focus())
return () => cancelAnimationFrame(raf)
}, [companiesOpen])
const close = useCallback(() => {
setOpen(false)
setCompaniesOpen(false)
setQuery('')
}, [])
// Outside click. Two carve-outs: elements already removed from the DOM
// (isConnected: clicking a row that re-renders must not read as outside,
// known concept bug pattern), and portaled dialogs (the support dialog
// opens above the menu; interacting with it must not unmount it).
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.isConnected) return
if (target.closest('[role="dialog"]')) return
if (
(!triggerRef.current || !triggerRef.current.contains(target)) &&
(!menuRef.current || !menuRef.current.contains(target))
) {
close()
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open, close])
useEffect(() => {
if (!open) return
function handleKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return
// Esc closes the flyout first, then the menu; but never while the
// support dialog is open (it handles its own Esc).
if (document.querySelector('[role="dialog"]')) return
if (companiesOpen) {
setCompaniesOpen(false)
setQuery('')
} else {
close()
}
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open, companiesOpen, close])
const handleSwitch = async (companyId: string) => {
if (company && companyId === company.id) {
close()
return
}
setIsPending(true)
const result = await performCompanySwitch(companyId)
if (result?.error) {
setIsPending(false)
toast({
title: tSwitcher(
result.error === 'not_member' ? 'error_no_access' : 'error_switch_failed',
),
variant: 'destructive',
})
}
}
const filteredCompanies = companies.filter(({ company: c }) =>
c.name.toLowerCase().includes(query.trim().toLowerCase()),
)
const menuRow =
'flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-[13px] ' +
'text-muted-foreground hover:text-foreground hover:bg-secondary/60 ' +
'transition-colors duration-150 cursor-pointer'
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => (open ? close() : setOpen(true))}
aria-expanded={open}
aria-haspopup="menu"
className={cn(
'group flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-secondary/60',
collapsed ? 'justify-center p-2' : 'gap-2.5 px-3 py-2',
)}
>
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-secondary text-[11px] font-semibold uppercase text-foreground">
{accountInitial(userName, userEmail)}
</span>
{!collapsed && (
<>
<span className="flex-1 min-w-0">
<span className="block truncate text-[13px] font-medium text-foreground leading-tight">
{userName?.trim() || userEmail || tNav('mitt_konto')}
</span>
{company && (
<span className="block truncate text-[11px] text-muted-foreground leading-tight">
{company.name}
</span>
)}
</span>
<ChevronsUpDown className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground opacity-50 group-hover:opacity-100" />
</>
)}
</button>
{open &&
createPortal(
<div
ref={menuRef}
role="menu"
className="fixed z-[60] w-[232px] rounded-lg border border-border bg-popover py-1 shadow-lg animate-in fade-in slide-in-from-bottom-1 duration-150"
style={{ top: menuPos.top, left: menuPos.left }}
>
{/* Identity */}
{(userName || userEmail) && (
<div className="border-b border-border/60 px-3 py-2.5">
{userName && (
<p className="truncate text-[13px] font-medium text-foreground">{userName}</p>
)}
{userEmail && (
<p className="truncate text-[11px] text-muted-foreground">{userEmail}</p>
)}
</div>
)}
{/* Company switcher flyout */}
<div className="relative px-1 pt-1">
<button
type="button"
onClick={() => setCompaniesOpen((v) => !v)}
aria-expanded={companiesOpen}
className={cn(menuRow, companiesOpen && 'bg-secondary/60 text-foreground')}
>
<Building2 className="h-4 w-4 flex-shrink-0" />
<span className="flex-1 truncate">
{company?.name || tSwitcher('default_company_name')}
</span>
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 opacity-50" />
</button>
{companiesOpen && (
// Top-aligned with the company row, growing DOWNWARD
// (founder feedback 2026-07-23: never upward over the menu).
<div className="absolute top-0 left-full z-[61] ml-1.5 w-60 rounded-lg border border-border bg-popover py-1 shadow-lg animate-in fade-in slide-in-from-left-1 duration-150">
<div className="flex items-center gap-2 border-b border-border/60 px-3 pb-2 pt-1.5">
<Search className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
<input
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={tSwitcher('search_placeholder')}
className="w-full bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
/>
</div>
<div className="max-h-56 overflow-y-auto px-1 py-1" role="listbox">
{filteredCompanies.length === 0 && (
<p className="px-2.5 py-2 text-[12px] text-muted-foreground">
{tSwitcher('no_results')}
</p>
)}
{filteredCompanies.map(({ company: c, role }) => (
<button
key={c.id}
onClick={() => handleSwitch(c.id)}
disabled={isPending}
role="option"
aria-selected={c.id === company?.id}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] leading-snug transition-colors',
c.id === company?.id
? 'bg-secondary/60 text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
isPending && 'opacity-50',
)}
>
<Building2 className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{c.name}</span>
{role !== 'owner' && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground/60">
{role}
</span>
)}
{c.id === company?.id && (
<Check className="h-3.5 w-3.5 flex-shrink-0 text-primary" />
)}
{isPending && c.id !== company?.id && (
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-muted-foreground" />
)}
</button>
))}
</div>
{!sandbox && (
<div className="border-t border-border/60 px-1 pt-1">
<Link href="/select-company" onClick={close} className={menuRow}>
<Plus className="h-4 w-4 flex-shrink-0" />
{tSwitcher('add_company')}
</Link>
</div>
)}
</div>
)}
</div>
{/* Account links */}
<div className="px-1 pb-1">
<Link href="/settings" onClick={close} className={menuRow}>
<Settings className="h-4 w-4 flex-shrink-0" />
{tNav('settings')}
</Link>
<Link href="/settings/team" onClick={close} className={menuRow}>
<Users className="h-4 w-4 flex-shrink-0" />
{tNav('members_roles')}
</Link>
<Link href="/settings/billing" onClick={close} className={menuRow}>
<CreditCard className="h-4 w-4 flex-shrink-0" />
{tNav('subscription')}
</Link>
<div className="my-1 border-t border-border/60" />
<Link href="/help" onClick={close} className={menuRow}>
<HelpCircle className="h-4 w-4 flex-shrink-0" />
{tNav('help')}
</Link>
<a
href={DISCORD_INVITE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={close}
className={menuRow}
>
<MessagesSquare className="h-4 w-4 flex-shrink-0" />
{tNav('discord_community')}
</a>
<SupportLink variant="muted" className={menuRow} />
<div className="my-1 border-t border-border/60" />
<button
type="button"
onClick={() => {
close()
onLogout()
}}
className={cn(menuRow, 'text-destructive hover:text-destructive')}
>
<LogOut className="h-4 w-4 flex-shrink-0" />
{sandbox ? tNav('logout_sandbox') : tCommon('logout')}
</button>
</div>
</div>,
document.body,
)}
</>
)
}
+38
View File
@@ -0,0 +1,38 @@
'use client'
import { switchCompany } from '@/lib/company/actions'
/**
* Client-side company switch: persists the new active company via the
* server action, notifies sibling tabs, then hard-reloads onto the new
* company. Shared by CompanySwitcher (mobile sheet) and the sidebar
* user-menu flyout so both use the exact same mechanism.
*
* Returns an error code instead of navigating when the switch fails.
*/
export async function performCompanySwitch(
companyId: string,
): Promise<{ error: string } | void> {
const result = await switchCompany(companyId)
if (result.error) {
return { error: result.error }
}
// Notify every other open tab of the same user so they hard-reload
// onto the new company. BroadcastChannel is best-effort: if the
// browser doesn't support it (very old) we still hard-reload
// ourselves, and other tabs will self-correct via the visibilitychange
// / pageshow listeners in CompanyTabSync on their next focus event.
if (typeof BroadcastChannel !== 'undefined') {
try {
const channel = new BroadcastChannel('gnubok-company-switch')
channel.postMessage({ companyId })
channel.close()
} catch {
// Ignore: hard reload still happens below
}
}
// Hard navigation: tears down React state, router cache, in-flight
// fetches, blob URLs, etc. This is the whole point: nothing from the
// previous company can survive the switch.
window.location.assign('/')
}
+13 -4
View File
@@ -97,11 +97,11 @@
"time_tracking": "Time tracking",
"expenses": "Expenses",
"receipts": "Receipts",
"deadlines": "Deadlines",
"deadlines": "Important dates",
"vat_declaration": "VAT",
"skattekonto": "Tax account",
"periodiseringar": "Accruals",
"year_end": "Year-end",
"year_end": "Annual accounts",
"annual_report": "Annual report",
"income_declaration": "Income tax return",
"pending": "Pending",
@@ -129,7 +129,14 @@
"logout_sandbox": "Exit sandbox",
"trial_days_left": "Trial: {days, plural, =1 {1 day} other {# days}} left",
"ext_tic": "Company profile",
"ext_invoice_inbox": "Document inbox"
"ext_invoice_inbox": "Document inbox",
"fold_register": "Registers",
"fold_bokslut": "Year-end",
"collapse_nav": "Collapse menu",
"expand_nav": "Expand menu",
"members_roles": "Members and roles",
"subscription": "Subscription",
"discord_community": "Discord community"
},
"agentKnowledge": {
"load_error_title": "Couldn't load the knowledge profile",
@@ -4447,7 +4454,9 @@
"default_company_name": "My business",
"add_company": "Add company",
"error_no_access": "You don't have access to this company.",
"error_switch_failed": "The company switch could not be saved. Please try again."
"error_switch_failed": "The company switch could not be saved. Please try again.",
"search_placeholder": "Search companies...",
"no_results": "No matching companies"
},
"transactions": {
"page_title": "Transactions",
+13 -4
View File
@@ -97,11 +97,11 @@
"time_tracking": "Tidrapportering",
"expenses": "Utlägg",
"receipts": "Kvitton",
"deadlines": "Deadlines",
"deadlines": "Viktiga datum",
"vat_declaration": "Moms",
"skattekonto": "Skattekonto",
"periodiseringar": "Periodiseringar",
"year_end": "Bokslut",
"year_end": "Årsbokslut",
"annual_report": "Årsredovisning",
"income_declaration": "Inkomstdeklaration",
"pending": "Väntande",
@@ -129,7 +129,14 @@
"logout_sandbox": "Avsluta sandbox",
"trial_days_left": "Provperiod: {days, plural, =1 {1 dag} other {# dagar}} kvar",
"ext_tic": "Företagsprofil",
"ext_invoice_inbox": "Dokumentinkorg"
"ext_invoice_inbox": "Dokumentinkorg",
"fold_register": "Register",
"fold_bokslut": "Bokslut",
"collapse_nav": "Minimera menyn",
"expand_nav": "Expandera menyn",
"members_roles": "Medlemmar och roller",
"subscription": "Abonnemang",
"discord_community": "Discord-community"
},
"agentKnowledge": {
"load_error_title": "Kunde inte läsa in kunskapsprofilen",
@@ -4447,7 +4454,9 @@
"default_company_name": "Min verksamhet",
"add_company": "Lägg till företag",
"error_no_access": "Du har inte tillgång till detta företag.",
"error_switch_failed": "Företagsbytet kunde inte sparas. Försök igen."
"error_switch_failed": "Företagsbytet kunde inte sparas. Försök igen.",
"search_placeholder": "Sök företag...",
"no_results": "Inga företag matchar"
},
"transactions": {
"page_title": "Transaktioner",
@@ -0,0 +1,16 @@
-- Per-user UI state bag (UI migration PR 2, dev_docs/ui_migration_plan.md).
-- Holds client-driven interface preferences that should follow the user
-- across devices: sidebar collapse (nav_collapsed), nav fold state
-- (nav_folds.register / nav_folds.bokslut), and later the split-button
-- last-used create modes (create_mode.*, plan PR 3/4).
--
-- Deliberately one jsonb column instead of per-preference columns: these
-- values are cosmetic, never load-bearing, and never queried server-side
-- except to echo back to the client. Existing RLS on user_preferences
-- (select/insert/update own row) covers it; no policy changes.
ALTER TABLE public.user_preferences
ADD COLUMN IF NOT EXISTS ui_state jsonb NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN public.user_preferences.ui_state IS
'Client-driven UI preferences (nav collapse/fold state, last-used create modes). Cosmetic only, never authoritative for business logic.';
+16
View File
@@ -59,10 +59,26 @@ export interface UserPreferences {
id: string
user_id: string
active_company_id: string | null
// Client-driven UI preferences (nav collapse/fold state, last-used create
// modes). jsonb DEFAULT '{}'. Cosmetic only, never load-bearing.
ui_state?: UserUiState
created_at: string
updated_at: string
}
// Shape of user_preferences.ui_state. All fields optional: the bag grows
// as UI surfaces add preferences (UI migration plan PR 2/3).
export interface UserUiState {
nav_collapsed?: boolean
nav_folds?: {
register?: boolean
bokslut?: boolean
}
// Split-button last-used create modes, keyed per surface (plan PR 3/4),
// e.g. create_mode.bookkeeping = 'mall'.
create_mode?: Record<string, string>
}
// Transaction categories
export type TransactionCategory =
| 'income_services'