b6332e9ff4
* feat(salary): one-click AGI submission with filing state machine and success feedback The AGI panel required users to know that "Ladda ner AGI-fil" was the generate step, then click submit, signing link, and kvittens manually. A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path that does not exist. - New primary button "Lamna in till Skatteverket" chains the existing endpoints client-side: generate XML if missing, POST underlag, poll kontrollresultat, create signing link, open Mina Sidor in a tab opened synchronously at click (popup-blocker safe). Inline stepper shows each step; the four old buttons become collapsed advanced/recovery actions, auto-expanded in stale-draft and rejected states. XML download stays visible and free for manual filing. - deriveAgiFilingState() + useAgiSubmission() lift the per-period submission record to the run page: the progress rail and salary hero now render the real state machine (generated, underlag inskickat, vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of telling users to "lamna in" an already-submitted declaration. - Success card with kvittensnummer and signature metadata once signed, plus a toast when a poll flips the state while the page is open. - AGI kvittens cron every 15 min instead of every 2 h so filings signed on another device get stamped and emailed promptly. - Advanced submit also auto-generates, and the stale "Lon -> AGI -> Generera" error text now points at the real buttons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup The bank redirect landed on a blank page for the several seconds the callback spent exchanging the PSD2 session and mirroring accounts, and every failed connect attempt left a status='error' row that rendered forever as an "Atgard kravs" card next to a successful retry, showing duplicate connections to the same bank. - Stream a branded "Slutfor bankanslutningen" progress page from the callback: the shell flushes before the session exchange starts and a script/meta redirect follows when the work completes, with a 30s slow-work escape hatch. Fast outcomes (denial, bad params, unknown state) keep their plain redirects. - Delete never-activated connection rows (no session_id, no accounts_data) on denial or exchange failure, and sweep leftovers for the same bank on the next connect. Established connections keep their "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE SET NULL so deletion has no dependents. - Show "Banken ar ansluten: hamtar dina konton" while the settings panel loads after the callback instead of an anonymous spinner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip A direct POST to /api/invoices/[id]/send against an already-issued invoice re-emailed the customer and posted a second revenue verifikat (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id and orphaning the first entry. Only the UI hid the button; the v1 route and the MCP commit executor already rejected non-drafts. - Non-draft invoices now return 409 INVOICE_ALREADY_SENT. - The draft to sent status flip is an optimistic lock (status guard plus row-count check); journal entry, accrual schedules, PDF archival and the invoice.sent event only run for the request that won the flip. - On a flip failure the journal entry is deferred: the row stays draft and a retry re-runs the pipeline, ending with exactly one verifikat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send - sendInvoiceFromSchedule now auto-creates an online payment link via applyPaymentLinkToInvoice before rendering and passes the payment link QR to the PDF: parity with the dashboard and v1 send routes, which recurring invoices silently lacked. - The recurring cron persists last_run_warning both when a claimed run throws (hourly retries stay visible on the schedule) and when a stale schedule is rolled forward, so a deterministic failure can no longer skip a month silently. - Auto-send is blocked for sandbox companies at the email chokepoint (freeze-and-retain: the invoice is still generated as a draft), covering both the cron and the run-now route with one guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(salary): close the Fortnox payroll API gaps (phases 1-4) Payroll now runs end-to-end through the open API, including onboarding a client from another payroll system, with every write staged for approval. - v1: per-employee payslips (list/detail/PDF), payslip line writes, run roster attach/remove, absence ranges (per-day storage), jamkning fields, cutover opening balances (single + atomic bulk PUT), vacation balance + vacation-year-close. PUT added to the wrapper's idempotency/ test-key set (test keys could otherwise write through PUT). - MCP: 10 new tools (get_employee/get_payslip/list_absence/ get_vacation_balance reads + staged update_payslip_line, register_absence, create_employee, update_employee, set_employee_opening_balances, close_vacation_year), executors, risk tiers, op-type CHECK expansions. create_employee encrypts personnummer at staging: pending_operations never holds plaintext. - Scope-map audit retrofit: 11 formerly unmapped tools now scoped; BREAKING for keys that relied on the 4 default-allow writes. - Cutover: employee_opening_balances (derived lock trigger, self-unlocks on run correction), engine YTD/karens/liability integration, Ingaende saldon section in the employee editor. - Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the hourly/daily divisors; legacy 173/21 preserved exactly at defaults so existing pay math is byte-identical. - Vacation ledger + semesterberedning/arsavslut: recomputed per-year day balances (synced on book/correct, non-fatal), year-close with the min-20 floor, 5-year sparade-dagar expiry to forced payout, and a 2920/2940 drift adjustment via the bookkeeping engine; Semester dashboard card with preview-then-confirm dialog. - Fix: Zod 4 defaults leak through .partial(), which made every sparse employee PATCH fail validation and reset defaulted columns. Migrations 20260713100000/101000/110000/121000/122000 (applied to staging with version rows; prod via merge). vacation_ledger renamed from 20260713120000 to avoid colliding with vat_declaration_totals_rpc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC) The dominant cost was infrastructure: Vercel functions ran in iad1 (Washington D.C.) while Supabase (DB + auth) lives in eu-north-1 (Stockholm), so every request paid 4-5 transatlantic round trips of auth + company resolution before doing any real work (measured 530-1900ms for single-query GETs in prod logs). Pin functions to arn1 and cut the redundant work on top: - vercel.json: functions to arn1, same city as the database - getActiveCompanyId: preference + first-membership queries run in parallel; the fallback result doubles as validation in the common single-company case (one round trip instead of two sequential) - withRouteContext: Server-Timing header and authMs/companyMs/handlerMs in the op-completed log, so latency is attributable per phase - dashboard layout: nav badge counts off the critical path; DashboardNav loads them client-side via the new use-worklist-badges SWR hook with debounced realtime revalidation - swr (new dependency, approved): global provider; useCompanySettings shares one cache entry across consumers and renders from cache on back-navigation instead of re-showing skeletons - /pending: realtime refetch debounced; bulk operations previously fired 4 requests per row-change event - VAT declaration: new get_vat_declaration_totals RPC returns per-account totals, settlement-shape detection (#984) and source_type counts in ONE round trip instead of paging every entry+line through PostgREST. Account lists stay TS-side parameters so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts; DDL already applied to staging. - bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat dynamic-imports the markdown parser, @vercel/speed-insights (new dependency, approved) added for real-user timings The /salary fetch-waterfall fix from the same effort already landed inside 2084a756. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): settle öre-rounded payments from the mark-paid flow An invoice with öresavrundning shows a rounded "Att betala" on the PDF; the customer pays that amount (up to 50 öre off the stored öre total) and the invoice-page mark-paid flow rejected it with MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction match flow already absorbed the residual to 3740. - PaymentBookingDialog now proposes the rounded bank leg plus the 3740 residual line (credit when rounded up, debit when rounded down), resolved via getDisplayTotal from the per-invoice override and company_settings.ore_rounding. - settleInvoicePayment and the v1 mark-paid route absorb the sub-krona residual, gated by planInvoicePaymentForLines: absorption applies ONLY when the caller lines carry the exact residual on 3740; otherwise the strict plan applies (sub-krona partials stay partial, no-3740 overshoots keep the 400), so the GL can never diverge from the AR sub-ledger. - planInvoicePayment absorb-band boundary tightened to >= 1 kr: an exactly-1-kr overshoot used to slip past both the guard and the absorb branch and silently over-record paid_amount (pre-existing on the bank-match path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): resolve all 7 PR compliance findings - ASVS V3.3: per-request CSP nonce on the enable-banking finalize page (mirrors the mcp-oauth consent page); inline scripts are nonce-bound - ASVS V16: decouple callback finalize work from the response stream (eager promise + next/server after()) so a client disconnect cannot drop session persistence or the consent_granted audit emit - ISO 27001 A.8.15: failed audit-event emits log through the structured logger with a stable message for log-based alerting - ASVS V2.3: recurring-invoice cron and run-now routes resolve isSandboxCompany themselves and pass an explicit suppressAutoSend flag (defence in depth around the email chokepoint, freeze-and-retain kept) - ISO 27001 A.8.11: stagePendingOperation rejects plaintext personnummer-bearing keys in params/preview_data (key-based guard; EF org numbers make value-matching unsafe) - ASVS V4.5: employee PATCH body is truly sparse; cleared number fields are omitted instead of resetting DB values to hardcoded fallbacks - ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by convention, not 403) on the payslip PDF endpoint Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: implement vacation-year basis change validation and error handling - Added tests to block vacation-year basis changes when open balances exist. - Implemented error handling for open-balances guard query failures in the settings route. - Enhanced absence route to reject reversed date ranges with a validation error. - Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability. - Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules. - Improved error messaging for vacation year closure adjustments. - Adjusted employee opening balances handling to preserve audit information during upserts. * feat(settings): add validation to block vacation-year basis change with open balances feat(absence): reject reversed date ranges in absence queries fix(absence): update absence handling to use atomic upserts instead of delete+insert fix(employee): improve validation for jamkning dates in employee updates fix(opening-balances): ensure created_by field is preserved during upserts test(absence): enhance tests for absence range and date validations test(calculation): add tests for age-based avgifter rates and edge cases test(semesterberedning): validate vacation year closure adjustments and error handling test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema * fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
307 lines
13 KiB
TypeScript
307 lines
13 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,
|
|
Building2,
|
|
BookOpen,
|
|
ListTree,
|
|
BarChart3,
|
|
Upload,
|
|
Package,
|
|
ClipboardCheck,
|
|
HandCoins,
|
|
Wand2,
|
|
Inbox,
|
|
TrendingUp,
|
|
Settings,
|
|
HelpCircle,
|
|
ArrowRight,
|
|
type LucideIcon,
|
|
} from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { useCompany } from '@/contexts/CompanyContext'
|
|
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: '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' },
|
|
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions' },
|
|
{ id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' },
|
|
{ id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reports/bank-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()
|
|
|
|
// 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])
|
|
|
|
const annaFallback: Entry | null = q && filteredActions.length === 0 && filteredPages.length === 0
|
|
? {
|
|
id: 'anna-fallback',
|
|
label: `Fråga Anna: "${query.trim()}"`,
|
|
icon: Wand2,
|
|
href: `/chat?prompt=${encodeURIComponent(query.trim())}`,
|
|
}
|
|
: q
|
|
? {
|
|
id: 'anna-followup',
|
|
label: `Fråga Anna istället: "${query.trim()}"`,
|
|
icon: Wand2,
|
|
href: `/chat?prompt=${encodeURIComponent(query.trim())}`,
|
|
}
|
|
: null
|
|
|
|
const flatEntries: Entry[] = [
|
|
...(annaFallback && filteredActions.length === 0 && filteredPages.length === 0 ? [annaFallback] : []),
|
|
...filteredActions,
|
|
...filteredPages,
|
|
...(annaFallback && (filteredActions.length > 0 || filteredPages.length > 0) ? [annaFallback] : []),
|
|
]
|
|
|
|
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>
|
|
)}
|
|
{annaFallback && (
|
|
<Section title="Anna">
|
|
<Row
|
|
entry={annaFallback}
|
|
active={flatEntries.indexOf(annaFallback) === activeIndex}
|
|
onSelect={() => commit(annaFallback)}
|
|
onHover={() => setActiveIndex(flatEntries.indexOf(annaFallback))}
|
|
/>
|
|
</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>
|
|
)
|
|
}
|