diff --git a/DECISIONS.md b/DECISIONS.md index ac3d9b68..53756d16 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -338,3 +338,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/customers/loading.tsx b/app/(dashboard)/customers/loading.tsx new file mode 100644 index 00000000..6ee6fc09 --- /dev/null +++ b/app/(dashboard)/customers/loading.tsx @@ -0,0 +1,31 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function CustomersLoading() { + return ( +
+
+ +
+ + +
+
+ +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ +
+ + + +
+
+ ))} +
+
+ ) +} diff --git a/app/(dashboard)/deadlines/loading.tsx b/app/(dashboard)/deadlines/loading.tsx new file mode 100644 index 00000000..968d7324 --- /dev/null +++ b/app/(dashboard)/deadlines/loading.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function DeadlinesLoading() { + return ( +
+ +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+ + +
+
+ + +
+ +
+ ))} +
+
+ ) +} diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 6cbb890b..abb80c12 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -273,7 +273,7 @@ export default function InvoicesPage() { - + {isLoading ? ( [1, 2, 3].map((i) => (
diff --git a/app/(dashboard)/kpi/loading.tsx b/app/(dashboard)/kpi/loading.tsx new file mode 100644 index 00000000..537c331f --- /dev/null +++ b/app/(dashboard)/kpi/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function KpiLoading() { + return ( +
+
+ + +
+
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ + + +
+ ))} +
+
+ + +
+
+ ) +} diff --git a/app/(dashboard)/pending/loading.tsx b/app/(dashboard)/pending/loading.tsx new file mode 100644 index 00000000..8a9e2444 --- /dev/null +++ b/app/(dashboard)/pending/loading.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function PendingLoading() { + return ( +
+
+ + +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ +
+ + +
+
+ + +
+
+ ))} +
+
+ ) +} diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index dd9c7bc4..730d81c6 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -1055,7 +1055,7 @@ export default function PendingOperationsPage() {
- + {showBulkControls && bulkEligible.length > 0 && (
diff --git a/app/(dashboard)/supplier-invoices/loading.tsx b/app/(dashboard)/supplier-invoices/loading.tsx new file mode 100644 index 00000000..6bfed8e3 --- /dev/null +++ b/app/(dashboard)/supplier-invoices/loading.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function SupplierInvoicesLoading() { + return ( +
+
+ + +
+
+ + +
+
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+ + +
+
+ + +
+
+ ))} +
+
+ ) +} diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index 6625e288..f2c731e7 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -215,7 +215,7 @@ export default function SupplierInvoicesPage() { {t('th_status')} - + {filteredInvoices.map((inv) => ( {inv.arrival_number} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index d082bfaa..94b9ede4 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -2220,7 +2220,7 @@ export default function TransactionsPage() { {/* Content based on mode */} {isLoading ? ( - + {[1, 2, 3].map((i) => (
@@ -2239,7 +2239,7 @@ export default function TransactionsPage() { onCreateTransaction={() => setIsDialogOpen(true)} /> ) : ( - + {(sourceFilter !== 'all' || (skvUnmatched.length > 0 && uncategorizedTransactions.length > 0)) && ( diff --git a/app/globals.css b/app/globals.css index 28aa39af..01bc874f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 91b7fc7c..c89c3aa7 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -672,7 +672,7 @@ export default function JournalEntryList() { // Verifikat/Utkast toggle stays reachable. if (!loading && entries.length === 0 && !hasActiveFilters && listMode === 'committed' && draftCount === 0) { return ( - + } title={t('empty_title')} @@ -933,7 +933,7 @@ export default function JournalEntryList() {
{loading ? ( - + ) : 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). - + ) : ( - + {/* 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. */} diff --git a/components/common/ContextPicker.tsx b/components/common/ContextPicker.tsx new file mode 100644 index 00000000..bdc00c32 --- /dev/null +++ b/components/common/ContextPicker.tsx @@ -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(null) + const listRef = useRef(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 ( + <> + + + {open && + createPortal( +
+
+ {items.map((item) => ( + + ))} +
+
, + document.body, + )} + + ) +} diff --git a/components/common/FyPicker.tsx b/components/common/FyPicker.tsx new file mode 100644 index 00000000..df7b1324 --- /dev/null +++ b/components/common/FyPicker.tsx @@ -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(() => + 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 ( + + ) +} diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 337d009a..d54c7590 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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 - // Persist a partial ui_state patch. Fire-and-forget: this is cosmetic - // preference data; a lost write self-corrects on the next toggle. - const persistUiState = (patch: Partial) => { - void fetch('/api/user/ui-state', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }).catch(() => {}) - } - // Sidebar collapse (64px icon rail). The width is CSS-variable-driven: // #dash-shell sets --nav-w inline (server-rendered from ui_state), and // both the aside and
read it, so one property flip resizes the diff --git a/components/ui/attn-line.tsx b/components/ui/attn-line.tsx new file mode 100644 index 00000000..65f48530 --- /dev/null +++ b/components/ui/attn-line.tsx @@ -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 ( +

+ {children} + {action && ( + <> + {' '} + {action.href ? ( + + {action.label} + + ) : ( + + )} + + )} +

+ ) +} diff --git a/components/ui/confirm-dialog.tsx b/components/ui/confirm-dialog.tsx new file mode 100644 index 00000000..d10af8ba --- /dev/null +++ b/components/ui/confirm-dialog.tsx @@ -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 + /** 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 ( + !pending && onOpenChange(next)}> + + + + {title} + + {description && ( + + {description} + + )} + + {children} + + + + + + + ) +} diff --git a/components/ui/help-popover.tsx b/components/ui/help-popover.tsx new file mode 100644 index 00000000..467a442a --- /dev/null +++ b/components/ui/help-popover.tsx @@ -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(null) + const panelRef = useRef(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 ( + <> + + + {open && + createPortal( +
+ {children} +
, + document.body, + )} + + ) +} diff --git a/components/ui/page-header.tsx b/components/ui/page-header.tsx index fbecdc5d..95eb486b 100644 --- a/components/ui/page-header.tsx +++ b/components/ui/page-header.tsx @@ -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 .... + */ + help?: React.ReactNode } -export function PageHeader({ title, description, action }: PageHeaderProps) { +export function PageHeader({ title, description, action, help }: PageHeaderProps) { return (
- {/* Locked at exactly 24px/32px (UI-migration convention 2) */} -

{title}

+
+ {/* Locked at exactly 24px/32px (UI-migration convention 2) */} +

{title}

+ {help} +
{description && (

{description}

)} diff --git a/components/ui/row-status.tsx b/components/ui/row-status.tsx new file mode 100644 index 00000000..47347036 --- /dev/null +++ b/components/ui/row-status.tsx @@ -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` and pass the resolved entry. + */ +export function RowStatus({ status }: { status: RowStatusDescriptor }) { + if (status.exception) { + return {status.label} + } + return {status.label} +} diff --git a/components/ui/slide-over.tsx b/components/ui/slide-over.tsx new file mode 100644 index 00000000..c5f5cd94 --- /dev/null +++ b/components/ui/slide-over.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + +)) +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 ( +
+
+
+ {kicker && ( +
+ {kicker} +
+ )} + + {title} + +
+ + + +
+
+ ) +} + +function SlideOverBody({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +function SlideOverFooter({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +export { + SlideOver, + SlideOverTrigger, + SlideOverClose, + SlideOverContent, + SlideOverHeader, + SlideOverBody, + SlideOverFooter, +} diff --git a/components/ui/split-button.tsx b/components/ui/split-button.tsx new file mode 100644 index 00000000..ad181b9a --- /dev/null +++ b/components/ui/split-button.tsx @@ -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(null) + const menuRef = useRef(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 ( +
+ + + + {open && + createPortal( +
+
+ {options.map((option) => ( + + ))} +
+
, + document.body, + )} +
+ ) +} diff --git a/lib/ui-state/__tests__/client.test.ts b/lib/ui-state/__tests__/client.test.ts new file mode 100644 index 00000000..fbfcb88e --- /dev/null +++ b/lib/ui-state/__tests__/client.test.ts @@ -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') + }) +}) diff --git a/lib/ui-state/client.ts b/lib/ui-state/client.ts new file mode 100644 index 00000000..956e9d70 --- /dev/null +++ b/lib/ui-state/client.ts @@ -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): 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( + 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 +} diff --git a/messages/en.json b/messages/en.json index 7aa4f4eb..d9fe8c8d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -63,7 +63,8 @@ "sent": "Sent", "matched": "Matched", "unmatched": "Unmatched" - } + }, + "more_options": "More options" }, "nav": { "dashboard": "Overview", diff --git a/messages/sv.json b/messages/sv.json index 44e75cc0..50a247cb 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -63,7 +63,8 @@ "sent": "Skickad", "matched": "Matchad", "unmatched": "Omatchad" - } + }, + "more_options": "Fler alternativ" }, "nav": { "dashboard": "Översikt",