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:
co-authored by
Claude Fable 5
parent
6b506a9ca8
commit
d59e4708cf
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.';
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user