'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, Building2, BookOpen, ListTree, BarChart3, Upload, Package, ClipboardCheck, HandCoins, Wand2, Inbox, TrendingUp, Settings, HelpCircle, ArrowRight, Scale, type LucideIcon, } 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: Building2, 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(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) { 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 ( Snabbkommandon Sök efter sidor och åtgärder, eller fråga Anna.
{ 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" />
{filteredActions.length > 0 && (
{filteredActions.map((entry) => { const idx = flatEntries.indexOf(entry) return ( commit(entry)} onHover={() => setActiveIndex(idx)} /> ) })}
)} {filteredPages.length > 0 && (
{filteredPages.map((entry) => { const idx = flatEntries.indexOf(entry) return ( commit(entry)} onHover={() => setActiveIndex(idx)} /> ) })}
)} {assistantFallback && (
commit(assistantFallback)} onHover={() => setActiveIndex(flatEntries.indexOf(assistantFallback))} />
)} {flatEntries.length === 0 && (
Inget hittades. Tryck Enter eller börja om.
)}
↑↓ navigera · Enter välj · Esc stäng ⌘K
) } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
) } function Row({ entry, active, onSelect, onHover, }: { entry: Entry active: boolean onSelect: () => void onHover: () => void }) { const Icon = entry.icon return ( ) }