{children} diff --git a/app/api/user/ui-state/__tests__/route.test.ts b/app/api/user/ui-state/__tests__/route.test.ts new file mode 100644 index 00000000..d2782c30 --- /dev/null +++ b/app/api/user/ui-state/__tests__/route.test.ts @@ -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 } } + }>(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) + }) +}) diff --git a/app/api/user/ui-state/route.ts b/app/api/user/ui-state/route.ts new file mode 100644 index 00000000..87871af5 --- /dev/null +++ b/app/api/user/ui-state/route.ts @@ -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 } }) +} diff --git a/app/globals.css b/app/globals.css index b2cedc90..28aa39af 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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
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 */ diff --git a/app/layout.tsx b/app/layout.tsx index 55cc01ae..8b0166f0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ + diff --git a/components/ScrollbarReveal.tsx b/components/ScrollbarReveal.tsx new file mode 100644 index 00000000..a2c7c411 --- /dev/null +++ b/components/ScrollbarReveal.tsx @@ -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() + + 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 +} diff --git a/components/dashboard/CompanySwitcher.tsx b/components/dashboard/CompanySwitcher.tsx index 24f3e159..6052bde4 100644 --- a/components/dashboard/CompanySwitcher.tsx +++ b/components/dashboard/CompanySwitcher.tsx @@ -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") diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index f35b635d..337d009a 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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 = { + 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, 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 - const [manualCollapsed, setManualCollapsed] = useState>({ - 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) => { + 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
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>({ + 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) => ( + + {badge > 99 ? '99+' : badge} + + ) + + // 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', + ), + )} + {tNav(item.labelKey)} + {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 ? ( + + {content} + + ) : ( +
+ {content} +
+ ) + } + + // 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 ( +
+ +
+
+
+ {items.map((item) => renderSidebarItem(item, { child: true }))} +
+
+
+
+ ) + } + + // 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 = ( + + {renderNavIcon( + item, + cn('h-[17px] w-[17px]', active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'), + )} + {badge !== null && ( + + {badge > 99 ? '99' : badge} + + )} + + ) + 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 ? ( + + {inner} + + ) : ( +
+ {inner} +
+ ) + } + return ( <> {/* Desktop sidebar */} -