Files
accounted/components/common/CommandPalette.tsx
T
Jakob Wennberg 34b677b02c chore(ui): retire the Building2 icon app-wide (#2235)
Founder request from the register walkthrough. Suppliers (nav, command
palette, empty state) use Truck; company and company-scoped surfaces
(active company badge, invite, home signpost, SIE preview, template
scopes, TIC workspace and its manifest) use Briefcase; the two bank
contexts use Landmark. The extension icon resolver no longer maps
Building2; the generated sector definitions follow the manifest.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:02:14 +02:00

323 lines
14 KiB
TypeScript

'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import {
ReceiptText,
ArrowLeftRight,
Users,
Wallet,
BookOpen,
ListTree,
BarChart3,
Upload,
Package,
ClipboardCheck,
HandCoins,
Wand2,
Inbox,
TrendingUp,
Settings,
HelpCircle,
ArrowRight,
Scale,
type LucideIcon,
Truck,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
type Entry = {
id: string
label: string
hint?: string
icon: LucideIcon
href: string
keywords?: string
}
const ACTION_ENTRIES: Entry[] = [
{ id: 'new-invoice', label: 'Ny faktura', hint: 'Skapa & skicka faktura', icon: ReceiptText, href: '/invoices?new=1', keywords: 'fakturera ny invoice send create' },
{ id: 'book-transaction', label: 'Boka transaktion', hint: 'Gå till transaktionsinkorgen', icon: ArrowLeftRight, href: '/transactions', keywords: 'transaktion bokför kategorisera categorize' },
{ id: 'new-customer', label: 'Lägg till kund', icon: Users, href: '/customers', keywords: 'kund customer ny lägg till' },
{ id: 'new-supplier-invoice', label: 'Skapa leverantörsfaktura', icon: Wallet, href: '/supplier-invoices?new=1', keywords: 'leverantörsfaktura supplier invoice ny' },
{ id: 'reports', label: 'Visa resultaträkning', hint: 'Rapporter', icon: BarChart3, href: '/reports', keywords: 'rapport resultat balans report' },
]
const PAGE_ENTRIES: Entry[] = [
{ id: 'kunder', label: 'Kunder', icon: Users, href: '/customers' },
{ id: 'leverantörer', label: 'Leverantörer', icon: Truck, href: '/suppliers' },
{ id: 'kundfakturor', label: 'Kundfakturor', icon: ReceiptText, href: '/invoices', keywords: 'fakturor fakturering invoices kundfaktura' },
{ id: 'leverantörsfakturor', label: 'Leverantörsfakturor', icon: Wallet, href: '/supplier-invoices' },
{ id: 'bokföring', label: 'Bokföring', icon: BookOpen, href: '/bookkeeping', keywords: 'verifikat journal ledger' },
{ id: 'kontoplan', label: 'Kontoplan', icon: ListTree, href: '/chart-of-accounts', keywords: 'kontoplan konton bas chart of accounts konto' },
{ id: 'anläggningstillgångar', label: 'Anläggningstillgångar', icon: Package, href: '/assets', keywords: 'tillgångar assets' },
{ id: 'rapporter', label: 'Rapporter', icon: BarChart3, href: '/reports' },
{ id: 'rapport-resultatrapport', label: 'Visa rapport: Resultatrapport', icon: BarChart3, href: '/reports/resultatrapport', keywords: 'rapport resultat intäkter kostnader' },
{ id: 'rapport-balansrapport', label: 'Visa rapport: Balansrapport', icon: BarChart3, href: '/reports/balansrapport', keywords: 'rapport balans tillgångar skulder saldo per konto' },
{ id: 'rapport-saldobalans', label: 'Visa rapport: Saldobalans', icon: BarChart3, href: '/reports/trial-balance', keywords: 'rapport saldobalans trial balance saldo per konto' },
{ id: 'rapport-moms', label: 'Visa rapport: Momsdeklaration', icon: BarChart3, href: '/reports/vat-declaration', keywords: 'rapport moms vat deklaration' },
// "verifikat" is the word a bookkeeper reaches for ("verifikat per konto"),
// and matches() requires every typed token, so leaving it out made the exact
// phrase return nothing even though this report is precisely the answer.
// Deliberately NOT carrying "stäm av"/"avstämning" here: the palette
// auto-selects the first hit and huvudbok is listed above Bankavstämning, so
// those words would hijack Enter from the reconciliation page. They live in
// ReportDescriptor.searchTerms instead, where the library shows a list.
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto verifikat verifikationer verifikationer per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions vouchers' },
{ id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' },
{ id: 'avstamning', label: 'Avstämning', hint: 'Stäm av bank och skattekonto', icon: Scale, href: '/reconciliation', keywords: 'avstämning stäm av bank skattekonto matcha reconcile reconciliation 1630 1930' },
{ id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' },
{ id: 'importera', label: 'Importera', icon: Upload, href: '/import' },
{ id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' },
{ id: 'löner', label: 'Löner', icon: HandCoins, href: '/salary' },
{ id: 'anställda', label: 'Anställda', icon: Users, href: '/salary/employees' },
{ id: 'dokumentinkorg', label: 'Dokumentinkorg', icon: Inbox, href: '/e/general/invoice-inbox' },
{ id: 'nyckeltal', label: 'Nyckeltal', icon: TrendingUp, href: '/kpi' },
{ id: 'inställningar', label: 'Inställningar', icon: Settings, href: '/settings' },
{ id: 'hjälp', label: 'Hjälp', icon: HelpCircle, href: '/help' },
]
function matches(entry: Entry, q: string): boolean {
const hay = `${entry.label} ${entry.hint ?? ''} ${entry.keywords ?? ''}`.toLowerCase()
return q.split(/\s+/).filter(Boolean).every(t => hay.includes(t))
}
export default function CommandPalette({ initialOpen = false }: { initialOpen?: boolean } = {}) {
const router = useRouter()
// initialOpen: LazyCommandPalette mounts this component on the first ⌘K,
// so the palette must come up already open rather than waiting for a
// second keypress.
const [open, setOpen] = useState(initialOpen)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const { capabilities } = useCompany()
const { identity } = useAgentSheet()
// Drop entries that jump to a paywalled extension workspace the active
// company can't reach (e.g. the AI-only Dokumentinkorg). The page itself is
// gated server-side; this keeps ⌘K from offering a dead destination.
const allowedByCapability = useMemo(() => {
return (entry: Entry) => {
const m = entry.href.match(/^\/e\/([^/]+)\/([^/?#]+)/)
if (!m) return true
const required = requiredCapabilityForExtension(m[1], m[2])
return !required || capabilities.includes(required)
}
}, [capabilities])
function handleOpenChange(next: boolean) {
setOpen(next)
if (!next) {
setQuery('')
setActiveIndex(0)
}
}
// Global ⌘K / Ctrl+K
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setOpen(prev => !prev)
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [])
useEffect(() => {
if (!open) return
const raf = requestAnimationFrame(() => inputRef.current?.focus())
return () => cancelAnimationFrame(raf)
}, [open])
const q = query.trim().toLowerCase()
const filteredActions = useMemo(() => {
const visible = ACTION_ENTRIES.filter(allowedByCapability)
return q ? visible.filter(e => matches(e, q)) : visible
}, [q, allowedByCapability])
const filteredPages = useMemo(() => {
const visible = PAGE_ENTRIES.filter(allowedByCapability)
return q ? visible.filter(e => matches(e, q)) : visible.slice(0, 6)
}, [q, allowedByCapability])
// The hand-off-to-assistant entries use the agent name the user chose in
// /onboarding/agent, and hide entirely until that onboarding is done: the
// same gate as the nav entry and the FAB.
const assistantName = identity.displayName?.trim() || 'assistenten'
const assistantFallback: Entry | null = !identity.isVerified || !q
? null
: filteredActions.length === 0 && filteredPages.length === 0
? {
id: 'assistant-fallback',
label: `Fråga ${assistantName}: "${query.trim()}"`,
icon: Wand2,
href: `/chat/new?prompt=${encodeURIComponent(query.trim())}`,
}
: {
id: 'assistant-followup',
label: `Fråga ${assistantName} istället: "${query.trim()}"`,
icon: Wand2,
href: `/chat/new?prompt=${encodeURIComponent(query.trim())}`,
}
const flatEntries: Entry[] = [
...(assistantFallback && filteredActions.length === 0 && filteredPages.length === 0 ? [assistantFallback] : []),
...filteredActions,
...filteredPages,
...(assistantFallback && (filteredActions.length > 0 || filteredPages.length > 0) ? [assistantFallback] : []),
]
function commit(entry: Entry) {
setOpen(false)
router.push(entry.href)
}
function onInputKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex(i => Math.min(flatEntries.length - 1, i + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex(i => Math.max(0, i - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
const target = flatEntries[activeIndex]
if (target) commit(target)
}
}
return (
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogPrimitive.Content
aria-label="Snabbkommandon"
className="fixed left-[50%] top-[20%] z-50 w-[calc(100vw-2rem)] max-w-xl translate-x-[-50%] rounded-xl border border-border bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogPrimitive.Title className="sr-only">Snabbkommandon</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Sök efter sidor och åtgärder, eller fråga Anna.
</DialogPrimitive.Description>
<div className="px-4 py-3 border-b border-border">
<input
ref={inputRef}
value={query}
onChange={e => { setQuery(e.target.value); setActiveIndex(0) }}
onKeyDown={onInputKey}
placeholder="Sök eller skriv vad du vill göra…"
aria-label="Sök eller skriv vad du vill göra"
className="w-full bg-transparent text-base placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="max-h-[60vh] overflow-y-auto py-1.5" role="listbox">
{filteredActions.length > 0 && (
<Section title="Åtgärder">
{filteredActions.map((entry) => {
const idx = flatEntries.indexOf(entry)
return (
<Row
key={entry.id}
entry={entry}
active={idx === activeIndex}
onSelect={() => commit(entry)}
onHover={() => setActiveIndex(idx)}
/>
)
})}
</Section>
)}
{filteredPages.length > 0 && (
<Section title="Sidor">
{filteredPages.map((entry) => {
const idx = flatEntries.indexOf(entry)
return (
<Row
key={entry.id}
entry={entry}
active={idx === activeIndex}
onSelect={() => commit(entry)}
onHover={() => setActiveIndex(idx)}
/>
)
})}
</Section>
)}
{assistantFallback && (
<Section title={assistantName}>
<Row
entry={assistantFallback}
active={flatEntries.indexOf(assistantFallback) === activeIndex}
onSelect={() => commit(assistantFallback)}
onHover={() => setActiveIndex(flatEntries.indexOf(assistantFallback))}
/>
</Section>
)}
{flatEntries.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
Inget hittades. Tryck Enter eller börja om.
</div>
)}
</div>
<div className="px-4 py-2 border-t border-border flex items-center justify-between text-[11px] text-muted-foreground">
<span>↑↓ navigera · Enter välj · Esc stäng</span>
<span className="font-mono">K</span>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-1.5 last:mb-0">
<p className="px-4 pt-2 pb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{title}</p>
<div>{children}</div>
</div>
)
}
function Row({
entry,
active,
onSelect,
onHover,
}: {
entry: Entry
active: boolean
onSelect: () => void
onHover: () => void
}) {
const Icon = entry.icon
return (
<button
type="button"
role="option"
aria-selected={active}
onMouseEnter={onHover}
onFocus={onHover}
onClick={onSelect}
className={cn(
'w-full text-left flex items-center gap-3 px-4 py-2 text-sm transition-colors',
active ? 'bg-secondary text-foreground' : 'text-foreground hover:bg-secondary/60',
)}
>
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="flex-1 truncate">{entry.label}</span>
{entry.hint && <span className="text-xs text-muted-foreground">{entry.hint}</span>}
{active && <ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />}
</button>
)
}