Files
accounted/components/common/CommandPalette.tsx
T
c609dbb228 feat(reconciliation): the Avstämning page: one body for every account with an outside truth (#1834)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade

The engine half of the reconciliation page (design: Avstämningsmotorn).

- lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus
  anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket,
  händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad,
  bokfört), the item buckets the page shows (proposed, unmatched external,
  unmatched ledger, matched, ignored, upcoming), opening_difference,
  unexplained_difference (0,00 by construction when data is consistent),
  dead-link handling (a link to a reversed/draft entry counts as unlinked and is
  flagged), awaiting_external for ledger lines within 5 days of the snapshot,
  staleness, and a window that scopes item lists without hiding older rows.
  Core reads skattekonto_transactions and the extension's snapshot row directly;
  no @/extensions import.
- lib/reconciliation/gl-balance.ts: one ledger-balance helper with the
  trial-balance predicate status IN (posted, reversed). The drift check summed
  posted only, which misstated 1630 for any company with a storno on the account;
  skattekonto-drift.ts now delegates to the helper.
- Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id /
  suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls
  refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now
  assigns one-to-one across rows (AGI period first, then nearest date) and falls
  back to an entry whose 1630 lines net to the amount (split lines); a proposal
  is never a link.
- lib/reconciliation/service.ts + schemas.ts: the account-keyed facade
  (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts
  (enabled cash accounts folded per IBAN, skattekonto when configured) and
  getAccountStatus dispatching to the bank engine or the new one; shared Zod
  shapes for the v1 registry, MCP schemas and the UI (PR 2).

Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window,
window scoping, failed ledger read, live-linked entries never proposed; matcher
one-to-one and split-line cases; proposal refresh writes/clears; service
dedupe and dispatch. No UI in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet)

The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in
five places. Switch to roundOre from @/lib/money and ratchet the baseline down
by the three occurrences this removes net of the matcher rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation

PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door
calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link.

- lib/reconciliation/items.ts: listAccountItems per account_key, the page's
  buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored,
  upcoming), limit/offset; skattekonto from the engine, bank from the scoped
  transactions + unlinked GL lines (netted per entry).
- lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run,
  partial success with codes), unmatchLink, setItemIgnored; emits
  reconciliation.matched / reconciliation.unmatched.
- lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a
  skattekonto row (single line or entry net on 1630, live-link guard, race-safe
  update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry
  until its tests are ported.
- Dashboard routes /api/reconciliation/accounts[...]: list, status, items,
  links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply
  directly (a human clicked).
- v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six,
  withApiV1, new scopes reconciliation:read / reconciliation:write (write is a
  staging scope for SoD), Idempotency-Key + dry_run on writes, registered for
  OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes
  and their transactions:* scopes unchanged.
- MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path
  untouched), new gnubok_list_reconciliation_items (default catalog),
  gnubok_reconcile_match (stages reconciliation_match, preflight = status) and
  gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to
  stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry
  moved to search. Executors in commit.ts; risk tiers medium/low; migration pair
  20260823130000/130001 adds the two op types to the CHECK constraint (value
  list = live prod as of 2026-08-23 + the two); close_period loadout updated.

Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/
happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard
suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and
apiskill:check green; no type errors in changed files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard

The six new v1 reconciliation endpoints and the two new scopes were not
recorded in the spec snapshot, and setSkattekontoRowIgnored updated
through one conditional payload, which the phantom-column scanner cannot
read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): the Avstämning page, one body for every account with an outside truth

/reconciliation in Arbeta (after Transaktioner), on the approved layout:
an account rail on the left (bank accounts and the skattekonto, logo or
monogram, last fetch, status dot, URL-owned selection), and for the
selected account four tiles (outside, ledger, difference, unexplained),
the bridge that explains the difference, an actions row (link the
proposed pairs, book the unbooked skattekonto events, run the bank
matcher) and a full-width table banded by bucket with proposal rows
linkable one by one. Every read and write goes through the PR 2
dashboard routes, so the page shows exactly what the v1 API and the MCP
tools see.

Also: nav item, command palette entry, sv/en strings. Period picker,
manual match mode and sign-off are deliberately not here (PR 4/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:03:54 +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,
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: '/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()
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>
)
}