feat(ui): shared migration primitives (UI migration PR 3) (#1122)
* feat(ui): shared migration primitives (UI migration PR 3) The component kit every page migration (PR 4-8) builds on: - ContextPicker: the one-per-page chip-dropdown context scope (convention 8), right-aligned popover with checks and muted annotations - FyPicker: fiscal-year picker on ContextPicker with the same controlled API and per-company localStorage key as FiscalYearSelector, which it replaces page by page from PR 4 - SplitButton: primary + caret menu, last-used mode persisted per user via ui_state.create_mode (lib/ui-state/client, unit-tested); nav persistence refactored onto the same helper - ConfirmDialog: centered min-460px confirm-up-front dialog (convention 10) with pending state on an awaitable onConfirm - HelpPopover: 17px "?" after the H1 opening an anchored popover (convention 7); PageHeader gets a `help` slot - AttnLine: the one-ochre-sentence attention pattern (convention 6) with optional inline action; new AA-safe --attn token pair - RowStatus: chips-mark-exceptions helper (convention 5) - SlideOver: right review panel, 480px, 18px inset, rounded, veil + Esc (convention 13), with header kicker / body / footer slots - Stagger: .stagger-enter applied to the five target pages' list containers (bookkeeping, transactions, pending, invoices, supplier-invoices); structural loading.tsx added for supplier-invoices, customers, kpi, pending, deadlines No page adopts the new pickers/dialogs yet: that is PR 4-8, one page per PR against this kit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): FyPicker chip must not double the Rakenskapsar label Real fiscal periods are often named "Rakenskapsar 2026" already; only prefix the label when the period name lacks it. 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
d59e4708cf
commit
5b5ee8e429
@@ -338,3 +338,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[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.
|
||||
[2026-07-23] PR 3 primitives: new --attn token pair (38 50% 34% light / 38 45% 62% dark) for the one-sentence AttnLine instead of reusing --warning: the warning tone fails WCAG AA as 12.5px body text on the page background; chips/charts keep --warning. Stagger applied via className on the five target pages' DataList/TableBody containers (plan item 7), not baked into the DataList primitive: remaining pages adopt it in their own migration PRs where their skeletons are aligned at the same time.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function CustomersLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="rounded-lg border border-border">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function DeadlinesLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-8 w-44" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-4 rounded-lg border border-border p-4"
|
||||
>
|
||||
<div className="w-12 space-y-1">
|
||||
<Skeleton className="h-6 w-10" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-56" />
|
||||
<Skeleton className="h-3 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -273,7 +273,7 @@ export default function InvoicesPage() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DataList>
|
||||
<DataList className="stagger-enter">
|
||||
{isLoading ? (
|
||||
[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function KpiLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-9 w-44 rounded-full" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-4">
|
||||
<Skeleton className="mb-3 h-3 w-24" />
|
||||
<Skeleton className="mb-2 h-7 w-32" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-6">
|
||||
<Skeleton className="mb-4 h-4 w-40" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function PendingLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-9 w-36 rounded-full" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 border-b border-border/60 px-4 py-4 last:border-b-0"
|
||||
>
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-24 rounded-full" />
|
||||
<Skeleton className="h-8 w-24 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1055,7 +1055,7 @@ export default function PendingOperationsPage() {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<DataList>
|
||||
<DataList className="stagger-enter">
|
||||
{showBulkControls && bulkEligible.length > 0 && (
|
||||
<DataListHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function SupplierInvoicesLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-9 w-36 rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-72" />
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-44" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -215,7 +215,7 @@ export default function SupplierInvoicesPage() {
|
||||
<TableHead>{t('th_status')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableBody className="stagger-enter">
|
||||
{filteredInvoices.map((inv) => (
|
||||
<TableRow key={inv.id}>
|
||||
<TableCell className="tabular-nums">{inv.arrival_number}</TableCell>
|
||||
|
||||
@@ -2220,7 +2220,7 @@ export default function TransactionsPage() {
|
||||
|
||||
{/* Content based on mode */}
|
||||
{isLoading ? (
|
||||
<DataList>
|
||||
<DataList className="stagger-enter">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
@@ -2239,7 +2239,7 @@ export default function TransactionsPage() {
|
||||
onCreateTransaction={() => setIsDialogOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<DataList>
|
||||
<DataList className="stagger-enter">
|
||||
{(sourceFilter !== 'all'
|
||||
|| (skvUnmatched.length > 0 && uncategorizedTransactions.length > 0)) && (
|
||||
<DataListHeader>
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
--warning: 38 55% 50%;
|
||||
--warning-foreground: 0 0% 9%;
|
||||
|
||||
/* Attn: darker ochre for the one-sentence attention line (.attn / AttnLine),
|
||||
derived from --warning but AA-safe as small text on the page background. */
|
||||
--attn: 38 50% 34%;
|
||||
|
||||
--warm-accent: 38 45% 52%;
|
||||
|
||||
/* Chart colors */
|
||||
@@ -127,6 +131,8 @@
|
||||
--warning: 38 50% 55%;
|
||||
--warning-foreground: 38 50% 90%;
|
||||
|
||||
--attn: 38 45% 62%;
|
||||
|
||||
--warm-accent: 38 42% 58%;
|
||||
|
||||
--chart-1: 155 22% 48%;
|
||||
@@ -164,6 +170,7 @@
|
||||
--color-success-foreground: hsl(var(--success-foreground));
|
||||
--color-warning: hsl(var(--warning));
|
||||
--color-warning-foreground: hsl(var(--warning-foreground));
|
||||
--color-attn: hsl(var(--attn));
|
||||
--color-warm-accent: hsl(var(--warm-accent));
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
|
||||
@@ -672,7 +672,7 @@ export default function JournalEntryList() {
|
||||
// Verifikat/Utkast toggle stays reachable.
|
||||
if (!loading && entries.length === 0 && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
|
||||
return (
|
||||
<DataList>
|
||||
<DataList className="stagger-enter">
|
||||
<DataListEmpty
|
||||
icon={<BookOpen className="h-6 w-6" />}
|
||||
title={t('empty_title')}
|
||||
@@ -933,7 +933,7 @@ export default function JournalEntryList() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<DataList>
|
||||
<DataList className="stagger-enter">
|
||||
<DataListLoading />
|
||||
</DataList>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
@@ -941,7 +941,7 @@ export default function JournalEntryList() {
|
||||
// filtered committed view with no matches, or a committed view with no
|
||||
// posted entries yet (but drafts exist, hence we got here, not the
|
||||
// pristine early return above).
|
||||
<DataList>
|
||||
<DataList className="stagger-enter">
|
||||
<DataListEmpty
|
||||
icon={
|
||||
listMode === 'drafts' || !hasActiveFilters ? (
|
||||
@@ -967,7 +967,7 @@ export default function JournalEntryList() {
|
||||
/>
|
||||
</DataList>
|
||||
) : (
|
||||
<DataList>
|
||||
<DataList className="stagger-enter">
|
||||
{/* Batch-mark "Inget underlag krävs": select-all + contextual action bar,
|
||||
rendered as the list header so it reads as part of the ledger rather
|
||||
than a detached box above it. */}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface ContextPickerItem {
|
||||
id: string
|
||||
label: string
|
||||
/** Muted right-side note on the row, e.g. "stängt" for a closed fiscal year. */
|
||||
annotation?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface ContextPickerProps {
|
||||
items: ContextPickerItem[]
|
||||
/** Selected item id. */
|
||||
value: string | null
|
||||
onChange: (id: string) => void
|
||||
/** Chip text, e.g. "Räkenskapsår 2026" or "Alla källor · 24 300 kr". */
|
||||
triggerLabel: string
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The page context picker (UI-migration convention 8): a chip-dropdown that
|
||||
* scopes the page to a fiscal year, account or source. One per page, far
|
||||
* right in the toolbar. A chip that looks like a picker must be a picker;
|
||||
* this is that picker.
|
||||
*/
|
||||
export function ContextPicker({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
triggerLabel,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: ContextPickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current || !listRef.current) return
|
||||
const t = triggerRef.current.getBoundingClientRect()
|
||||
const l = listRef.current.getBoundingClientRect()
|
||||
const margin = 8
|
||||
// Right-aligned under the chip (the picker lives far right in the
|
||||
// toolbar), clamped to the viewport.
|
||||
const left = Math.max(margin, Math.min(t.right - l.width, window.innerWidth - l.width - margin))
|
||||
const top = Math.min(t.bottom + 4, window.innerHeight - l.height - margin)
|
||||
setPos({ top, left })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const raf = requestAnimationFrame(() => updatePosition())
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [open, updatePosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.isConnected) return
|
||||
if (
|
||||
(!triggerRef.current || !triggerRef.current.contains(target)) &&
|
||||
(!listRef.current || !listRef.current.contains(target))
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-border px-3 py-[5px] text-[13px]',
|
||||
'text-foreground transition-colors duration-150',
|
||||
disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-secondary/60 cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="truncate max-w-[220px]">{triggerLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="fixed z-[60] min-w-[220px] max-w-[320px] rounded-lg border border-border bg-popover py-1 shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
<div className="max-h-72 overflow-y-auto px-1">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={item.id === value}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
onChange(item.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] leading-snug transition-colors',
|
||||
item.disabled
|
||||
? 'text-muted-foreground/40 cursor-not-allowed'
|
||||
: item.id === value
|
||||
? 'bg-secondary/60 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.annotation && (
|
||||
<span className="flex-shrink-0 text-[11px] text-muted-foreground/70">
|
||||
{item.annotation}
|
||||
</span>
|
||||
)}
|
||||
{item.id === value && (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import {
|
||||
STORAGE_KEY_PREFIX,
|
||||
ALL_YEARS_VALUE,
|
||||
} from '@/components/common/FiscalYearSelector'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
interface FyPickerProps {
|
||||
/** Current selection. `null` means "all years": no filter applied. */
|
||||
value: string | null
|
||||
/**
|
||||
* Called with the selected period id (or null for "all years") and the
|
||||
* matching FiscalPeriod so callers avoid an extra fetch.
|
||||
*/
|
||||
onChange: (periodId: string | null, period?: FiscalPeriod | null) => void
|
||||
/** Include an "Alla räkenskapsår" option that clears the filter. */
|
||||
includeAllOption?: boolean
|
||||
/** Only show periods that have started (Reports-style filter). */
|
||||
hideFuturePeriods?: boolean
|
||||
/** Fires once after the initial period load completes. */
|
||||
onReady?: () => void
|
||||
/** Server-loaded periods for the first render, scoped to initialCompanyId. */
|
||||
initialPeriods?: FiscalPeriod[]
|
||||
initialCompanyId?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
function preparePeriods(periods: FiscalPeriod[], hideFuturePeriods: boolean): FiscalPeriod[] {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return periods
|
||||
.filter((p) => !hideFuturePeriods || p.period_start <= today)
|
||||
.sort((a, b) => b.period_start.localeCompare(a.period_start))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiscal-year context picker (UI-migration plan PR 3): the chip-dropdown
|
||||
* "Räkenskapsår 2026" with a check on the active choice and closed/locked
|
||||
* years annotated. Same controlled API and per-company localStorage
|
||||
* persistence as FiscalYearSelector, which it replaces page by page from
|
||||
* PR 4 on.
|
||||
*/
|
||||
export function FyPicker({
|
||||
value,
|
||||
onChange,
|
||||
includeAllOption = true,
|
||||
hideFuturePeriods = false,
|
||||
onReady,
|
||||
initialPeriods,
|
||||
initialCompanyId,
|
||||
className,
|
||||
}: FyPickerProps) {
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('fiscal_year')
|
||||
const canUseInitial = initialCompanyId === company?.id && initialPeriods !== undefined
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>(() =>
|
||||
canUseInitial ? preparePeriods(initialPeriods, hideFuturePeriods) : [],
|
||||
)
|
||||
const [loaded, setLoaded] = useState(canUseInitial)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) {
|
||||
onReady?.()
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
let fetched: FiscalPeriod[]
|
||||
if (initialCompanyId === company.id && initialPeriods !== undefined) {
|
||||
fetched = preparePeriods(initialPeriods, hideFuturePeriods)
|
||||
} else {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (!res.ok) {
|
||||
if (!cancelled) {
|
||||
setLoaded(true)
|
||||
onReady?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
const { data } = await res.json()
|
||||
fetched = preparePeriods(data || [], hideFuturePeriods)
|
||||
}
|
||||
if (cancelled) return
|
||||
|
||||
setPeriods(fetched)
|
||||
setLoaded(true)
|
||||
|
||||
// Restore last selection (same key as FiscalYearSelector so pages keep
|
||||
// their scope when the picker swaps in).
|
||||
if (value === null && typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id)
|
||||
if (stored === ALL_YEARS_VALUE) {
|
||||
if (includeAllOption) onChange(null, null)
|
||||
else if (fetched.length > 0) onChange(fetched[0].id, fetched[0])
|
||||
} else if (stored && fetched.some((p) => p.id === stored)) {
|
||||
onChange(stored, fetched.find((p) => p.id === stored) ?? null)
|
||||
} else if (!includeAllOption && fetched.length > 0) {
|
||||
onChange(fetched[0].id, fetched[0])
|
||||
}
|
||||
}
|
||||
|
||||
onReady?.()
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// onReady is a lifecycle callback: fire once per load, not on parent
|
||||
// re-renders that re-create it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [company?.id, hideFuturePeriods, includeAllOption, initialCompanyId, initialPeriods])
|
||||
|
||||
const handleChange = (id: string) => {
|
||||
const nextId = id === ALL_YEARS_VALUE ? null : id
|
||||
if (company?.id && typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY_PREFIX + company.id, nextId ?? ALL_YEARS_VALUE)
|
||||
}
|
||||
onChange(nextId, nextId ? periods.find((p) => p.id === nextId) ?? null : null)
|
||||
}
|
||||
|
||||
const annotationFor = (p: FiscalPeriod) =>
|
||||
p.locked_at ? t('badge_locked').toLowerCase() : p.is_closed ? t('badge_closed').toLowerCase() : undefined
|
||||
|
||||
const selected = value ? periods.find((p) => p.id === value) : null
|
||||
// Real period names often already read "Räkenskapsår 2026"; only prefix
|
||||
// the label when the name is a bare year/name so the chip never doubles up.
|
||||
const chipLabel = (p: FiscalPeriod) =>
|
||||
p.name.toLowerCase().includes(t('label').toLowerCase())
|
||||
? p.name
|
||||
: `${t('label')} ${p.name}`
|
||||
const triggerLabel = selected
|
||||
? chipLabel(selected)
|
||||
: includeAllOption
|
||||
? t('all_years')
|
||||
: loaded
|
||||
? t('placeholder')
|
||||
: t('loading')
|
||||
|
||||
const items = [
|
||||
...(includeAllOption ? [{ id: ALL_YEARS_VALUE, label: t('all_years') }] : []),
|
||||
...periods.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
annotation: annotationFor(p),
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<ContextPicker
|
||||
items={items}
|
||||
value={value ?? (includeAllOption ? ALL_YEARS_VALUE : null)}
|
||||
onChange={handleChange}
|
||||
triggerLabel={triggerLabel}
|
||||
disabled={!loaded || periods.length === 0}
|
||||
ariaLabel={t('label')}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
|
||||
import { useWorklistBadges } from '@/lib/hooks/use-worklist-badges'
|
||||
import { persistUiState } from '@/lib/ui-state/client'
|
||||
import { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys'
|
||||
import type { EntityType, UserUiState } from '@/types'
|
||||
|
||||
@@ -314,16 +315,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
|
||||
type ExpandableGroup = Exclude<GroupKey, 'top'>
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AttnLineProps {
|
||||
children: React.ReactNode
|
||||
/** Optional inline action at the end of the sentence. */
|
||||
action?: { label: string; href?: string; onClick?: () => void }
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Attention is one ochre sentence, not a banner (UI-migration convention 6):
|
||||
* a single 12.5px line in the attn tone with an optional embedded action
|
||||
* link. Max one per page.
|
||||
*/
|
||||
export function AttnLine({ children, action, className }: AttnLineProps) {
|
||||
return (
|
||||
<p className={cn('text-[12.5px] leading-5 text-attn', className)}>
|
||||
{children}
|
||||
{action && (
|
||||
<>
|
||||
{' '}
|
||||
{action.href ? (
|
||||
<Link
|
||||
href={action.href}
|
||||
className="underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
/**
|
||||
* Body text that DESCRIBES THE OUTCOME up front ("Bokförs som verifikat
|
||||
* A-217 med 1 250,00 kr ...") instead of the page commenting afterwards
|
||||
* (UI-migration convention 10).
|
||||
*/
|
||||
description?: React.ReactNode
|
||||
/** Optional richer body (e.g. a kontering preview) rendered below the description. */
|
||||
children?: React.ReactNode
|
||||
confirmLabel: string
|
||||
cancelLabel?: string
|
||||
/** Await-able: the dialog shows a pending state until the promise settles. */
|
||||
onConfirm: () => void | Promise<void>
|
||||
/** Terracotta confirm for destructive outcomes (avvisa, makulera). */
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Small centered confirmation dialog (min 460px on desktop): confirm before
|
||||
* acting, describing the outcome, rather than commenting after the fact.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
destructive = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const tCommon = useTranslations('common')
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setPending(true)
|
||||
await onConfirm()
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !pending && onOpenChange(next)}>
|
||||
<DialogContent className="sm:min-w-[460px] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription className="text-[13px] leading-relaxed">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{children}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={pending}
|
||||
>
|
||||
{cancelLabel ?? tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={pending}
|
||||
className={cn(pending && 'cursor-wait')}
|
||||
>
|
||||
{pending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface HelpPopoverProps {
|
||||
/** Popover body: the page's help text (i18n `help_*` keys per namespace). */
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Page help behind a small "?" (UI-migration convention 7): a 17px circular
|
||||
* button right after the H1 opening a popover anchored at the button. No
|
||||
* instructional copy in the page flow.
|
||||
*/
|
||||
export function HelpPopover({ children, className }: HelpPopoverProps) {
|
||||
const tNav = useTranslations('nav')
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current || !panelRef.current) return
|
||||
const t = triggerRef.current.getBoundingClientRect()
|
||||
const p = panelRef.current.getBoundingClientRect()
|
||||
const margin = 8
|
||||
const left = Math.max(margin, Math.min(t.left, window.innerWidth - p.width - margin))
|
||||
const top = Math.min(t.bottom + 6, window.innerHeight - p.height - margin)
|
||||
setPos({ top, left })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const raf = requestAnimationFrame(() => updatePosition())
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [open, updatePosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.isConnected) return
|
||||
if (
|
||||
(!triggerRef.current || !triggerRef.current.contains(target)) &&
|
||||
(!panelRef.current || !panelRef.current.contains(target))
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-label={tNav('help')}
|
||||
className={cn(
|
||||
'inline-flex h-[17px] w-[17px] items-center justify-center rounded-full border border-border',
|
||||
'text-[11px] leading-none text-muted-foreground transition-colors duration-150',
|
||||
'hover:border-foreground/30 hover:text-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="note"
|
||||
className="fixed z-[60] w-[300px] rounded-lg border border-border bg-popover p-4 text-[13px] leading-relaxed text-foreground shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,14 +4,22 @@ interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
/**
|
||||
* Page help content, rendered as a small "?" popover right after the H1
|
||||
* (UI-migration convention 7). Pass a <HelpPopover>...</HelpPopover>.
|
||||
*/
|
||||
help?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||
export function PageHeader({ title, description, action, help }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<div>
|
||||
{/* Locked at exactly 24px/32px (UI-migration convention 2) */}
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{title}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Locked at exactly 24px/32px (UI-migration convention 2) */}
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{title}</h1>
|
||||
{help}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-1 text-balance">{description}</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
|
||||
export interface RowStatusDescriptor {
|
||||
label: string
|
||||
/**
|
||||
* True when the row DEVIATES from the normal state (Utkast, Förfallen,
|
||||
* Ej bokförd). Normal states render as muted text; a table where every
|
||||
* row carries the same chip is wrong (UI-migration convention 5).
|
||||
*/
|
||||
exception?: boolean
|
||||
/** Badge variant for exception states. */
|
||||
variant?: BadgeProps['variant']
|
||||
}
|
||||
|
||||
/**
|
||||
* Chips mark exceptions: renders a status as muted text for normal states
|
||||
* and as a Badge only when the row deviates. Pages define their status map
|
||||
* as `Record<Status, RowStatusDescriptor>` and pass the resolved entry.
|
||||
*/
|
||||
export function RowStatus({ status }: { status: RowStatusDescriptor }) {
|
||||
if (status.exception) {
|
||||
return <Badge variant={status.variant ?? 'warning'}>{status.label}</Badge>
|
||||
}
|
||||
return <span className="text-xs text-muted-foreground">{status.label}</span>
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Right slide-over for reviewing an object (UI-migration convention 13):
|
||||
* a 480px panel inset 18px from the frame edge, rounded, with veil, Esc
|
||||
* and click-outside. Create/confirm flows use the centered dialog instead;
|
||||
* this is the review surface (e.g. the Granskning detail).
|
||||
*/
|
||||
const SlideOver = DialogPrimitive.Root
|
||||
const SlideOverTrigger = DialogPrimitive.Trigger
|
||||
const SlideOverClose = DialogPrimitive.Close
|
||||
|
||||
const SlideOverContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/30 dark:bg-black/50',
|
||||
'data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0',
|
||||
)}
|
||||
/>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-[18px] right-[18px] bottom-[18px] z-50 flex w-[480px] max-w-[calc(100vw-36px)] flex-col',
|
||||
'rounded-xl border border-border bg-background shadow-[var(--shadow-lg)]',
|
||||
'duration-200 data-[state=open]:animate-in data-[state=open]:slide-in-from-right-8 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right-8 data-[state=closed]:fade-out-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
))
|
||||
SlideOverContent.displayName = 'SlideOverContent'
|
||||
|
||||
/**
|
||||
* Header block: kicker line (actor · risk · time), serif title, close
|
||||
* button. Body scrolls; header and footer stay put.
|
||||
*/
|
||||
function SlideOverHeader({
|
||||
kicker,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
kicker?: React.ReactNode
|
||||
title: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex-shrink-0 border-b border-border px-6 py-4', className)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{kicker && (
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{kicker}
|
||||
</div>
|
||||
)}
|
||||
<DialogPrimitive.Title className="font-display text-lg leading-6 tracking-tight">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-secondary/60 hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlideOverBody({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto px-6 py-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlideOverFooter({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-shrink-0 items-center justify-end gap-2 border-t border-border px-6 py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
SlideOver,
|
||||
SlideOverTrigger,
|
||||
SlideOverClose,
|
||||
SlideOverContent,
|
||||
SlideOverHeader,
|
||||
SlideOverBody,
|
||||
SlideOverFooter,
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||
import { rememberCreateMode } from '@/lib/ui-state/client'
|
||||
import { Check, ChevronDown, type LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface SplitButtonOption {
|
||||
key: string
|
||||
label: string
|
||||
icon?: LucideIcon
|
||||
/** Muted second line in the menu describing what the mode does. */
|
||||
description?: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
interface SplitButtonProps {
|
||||
options: SplitButtonOption[]
|
||||
/**
|
||||
* ui_state.create_mode key for last-used persistence (e.g. 'bookkeeping').
|
||||
* Omit to keep the split button stateless.
|
||||
*/
|
||||
persistKey?: string
|
||||
/**
|
||||
* Which option renders as the primary action on first paint: the
|
||||
* server-read last-used mode (resolveInitialMode) or the first option.
|
||||
*/
|
||||
initialModeKey?: string
|
||||
variant?: ButtonProps['variant']
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary action + caret menu (UI-migration convention 9): multiple create
|
||||
* paths collapse into one button whose primary face is the last-used mode,
|
||||
* persisted per user in user_preferences.ui_state.create_mode.
|
||||
*/
|
||||
export function SplitButton({
|
||||
options,
|
||||
persistKey,
|
||||
initialModeKey,
|
||||
variant = 'default',
|
||||
className,
|
||||
}: SplitButtonProps) {
|
||||
const tCommon = useTranslations('common')
|
||||
const [activeKey, setActiveKey] = useState(
|
||||
() => options.find((o) => o.key === initialModeKey)?.key ?? options[0]?.key,
|
||||
)
|
||||
const [open, setOpen] = useState(false)
|
||||
const caretRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
const active = options.find((o) => o.key === activeKey) ?? options[0]
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!caretRef.current || !menuRef.current) return
|
||||
const t = caretRef.current.getBoundingClientRect()
|
||||
const m = menuRef.current.getBoundingClientRect()
|
||||
const margin = 8
|
||||
const left = Math.max(margin, Math.min(t.right - m.width, window.innerWidth - m.width - margin))
|
||||
const top = Math.min(t.bottom + 4, window.innerHeight - m.height - margin)
|
||||
setPos({ top, left })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const raf = requestAnimationFrame(() => updatePosition())
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [open, updatePosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.isConnected) return
|
||||
if (
|
||||
(!caretRef.current || !caretRef.current.contains(target)) &&
|
||||
(!menuRef.current || !menuRef.current.contains(target))
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!active) return null
|
||||
|
||||
const runOption = (option: SplitButtonOption) => {
|
||||
setActiveKey(option.key)
|
||||
if (persistKey) rememberCreateMode(persistKey, option.key)
|
||||
option.onSelect()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex items-stretch', className)}>
|
||||
<Button
|
||||
variant={variant}
|
||||
className="rounded-r-none"
|
||||
onClick={() => runOption(active)}
|
||||
>
|
||||
{active.icon && <active.icon className="mr-1.5 h-4 w-4" />}
|
||||
{active.label}
|
||||
</Button>
|
||||
<Button
|
||||
ref={caretRef}
|
||||
variant={variant}
|
||||
aria-label={tCommon('more_options')}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={cn(
|
||||
'rounded-l-none px-2',
|
||||
variant === 'default' && 'border-l border-primary-foreground/20',
|
||||
variant === 'outline' && 'border-l-0',
|
||||
variant === 'secondary' && 'border-l border-foreground/10',
|
||||
)}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
className="fixed z-[60] min-w-[240px] rounded-lg border border-border bg-popover py-1 shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
<div className="px-1">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
runOption(option)
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors',
|
||||
'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{option.icon && (
|
||||
<option.icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] text-foreground">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="block text-[11px] leading-snug text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{option.key === activeKey && (
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Tests for the client-side ui_state helpers: persistence POST shape,
|
||||
* silent failure, and last-used split-button mode resolution.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { persistUiState, rememberCreateMode, resolveInitialMode } from '../client'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fetchMock.mockResolvedValue({ ok: true })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('persistUiState', () => {
|
||||
it('POSTs the patch to /api/user/ui-state', () => {
|
||||
persistUiState({ nav_collapsed: true })
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/user/ui-state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nav_collapsed: true }),
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows network failures', () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'))
|
||||
expect(() => persistUiState({ nav_collapsed: false })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rememberCreateMode', () => {
|
||||
it('nests the mode under the surface key', () => {
|
||||
rememberCreateMode('bookkeeping', 'mall')
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(body).toEqual({ create_mode: { bookkeeping: 'mall' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveInitialMode', () => {
|
||||
const keys = ['tomt', 'mall', 'assistent'] as const
|
||||
|
||||
it('returns the persisted mode when valid', () => {
|
||||
const uiState = { create_mode: { bookkeeping: 'mall' } }
|
||||
expect(resolveInitialMode(uiState, 'bookkeeping', keys, 'tomt')).toBe('mall')
|
||||
})
|
||||
|
||||
it('falls back when the persisted mode is stale', () => {
|
||||
const uiState = { create_mode: { bookkeeping: 'removed-mode' } }
|
||||
expect(resolveInitialMode(uiState, 'bookkeeping', keys, 'tomt')).toBe('tomt')
|
||||
})
|
||||
|
||||
it('falls back when nothing is persisted', () => {
|
||||
expect(resolveInitialMode(undefined, 'bookkeeping', keys, 'tomt')).toBe('tomt')
|
||||
expect(resolveInitialMode({}, 'other', keys, 'assistent')).toBe('assistent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import type { UserUiState } from '@/types'
|
||||
|
||||
/**
|
||||
* Fire-and-forget persistence of a partial user_preferences.ui_state patch
|
||||
* (nav collapse/folds, split-button last-used modes). Cosmetic preference
|
||||
* data: a lost write self-corrects on the next change, so failures are
|
||||
* swallowed deliberately.
|
||||
*/
|
||||
export function persistUiState(patch: Partial<UserUiState>): void {
|
||||
void fetch('/api/user/ui-state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the last-used mode of a split button (ui_state.create_mode),
|
||||
* keyed per surface (e.g. 'bookkeeping' -> 'mall').
|
||||
*/
|
||||
export function rememberCreateMode(surface: string, mode: string): void {
|
||||
persistUiState({ create_mode: { [surface]: mode } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which split-button mode to show as primary on first render:
|
||||
* the persisted last-used mode when it's still one of the valid options,
|
||||
* otherwise the given fallback. Guards against stale persisted keys after
|
||||
* an option is renamed or removed.
|
||||
*/
|
||||
export function resolveInitialMode<T extends string>(
|
||||
uiState: UserUiState | undefined | null,
|
||||
surface: string,
|
||||
validKeys: readonly T[],
|
||||
fallback: T,
|
||||
): T {
|
||||
const persisted = uiState?.create_mode?.[surface]
|
||||
if (persisted && (validKeys as readonly string[]).includes(persisted)) {
|
||||
return persisted as T
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
+2
-1
@@ -63,7 +63,8 @@
|
||||
"sent": "Sent",
|
||||
"matched": "Matched",
|
||||
"unmatched": "Unmatched"
|
||||
}
|
||||
},
|
||||
"more_options": "More options"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Overview",
|
||||
|
||||
+2
-1
@@ -63,7 +63,8 @@
|
||||
"sent": "Skickad",
|
||||
"matched": "Matchad",
|
||||
"unmatched": "Omatchad"
|
||||
}
|
||||
},
|
||||
"more_options": "Fler alternativ"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Översikt",
|
||||
|
||||
Reference in New Issue
Block a user