From ce6efdb3dc8f259a84f98cc0bfb19cb295351b47 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 13 Aug 2026 16:17:15 +0200 Subject: [PATCH] refactor(pending): one pending-op-owned preview for chat, /pending and flow views (#1537) * refactor(pending): one pending-op-owned preview for chat, /pending and flow views A staged pending_operation was rendered three separate ways: the /pending page's OperationPreview switch (8 specialized renderers keyed on operation_type), ApprovalCard's own PreviewBlock (near-duplicate renderers keyed on 4 hardcoded MCP tool names), and AgentChat's toolNameFor() hack that mapped stored operation_types onto 'gnubok_'-prefixed tool names on hydration. This is the weakest seam ahead of flow-run views (plan seam 8.3): every new operation type had to be taught to render in two places and silently degraded in the third. Now there is one owner: - components/pending-operations/OperationPreview.tsx: the /pending renderers moved verbatim, dispatched on operation_type, consumed by /pending, ApprovalCard and future flow-run views. - components/pending-operations/vocabulary.ts: operation labels, single-action warnings and the one canonical rejection-category list (ApprovalCard's copy was byte-identical and is deleted). - lib/pending-operations/tool-name.ts: the single translation point between bare operation_types and 'gnubok_' tool names, with tests. toolNameFor gotcha fixed on the way: ApprovalCard's old dispatch only recognized 4 tool names, so a hydrated card for any other operation type (attach_document_to_transaction, match_transaction_invoice, ...) silently fell back to a raw generic preview. Hydration now passes the stored operation_type straight through attachStagedOperations to the card, and live streamed cards derive it from the event's tool name, so every operation type keeps its specialized preview on resume. Per-surface chrome (list row on /pending vs inline chat card) is deliberately kept: only the preview + vocabulary were the duplicated seam. Co-Authored-By: Claude Fable 5 * chore: drop a stray hunt_title copy rename that rode along 'Kvittojakten' -> 'Leta efter underlag' in messages/sv.json was uncommitted working-tree state from another session, swept into the extraction commit by git add breadth. It is a product-naming call with no en.json counterpart and does not belong in this refactor; preserved in this branch's first commit if it turns out to be wanted. Co-Authored-By: Claude Fable 5 * fix(pending): carry params to chat previews; guard preview amounts CodeRabbit round on #1537, both real. (1) AttachDocumentPreview renders its DocumentViewButton from params.document_id, which neither chat path carried: the staged_operation stream event now includes the tool-use input (the same values the staging tool stored as pending_operations.params) and hydration selects the params column, so an attach-document card in chat shows its evidence button live and on resume. (2) InvoicePreview and CreateTransactionPreview cast amounts straight into formatCurrency; a payload without one rendered 'NaN kr'. They now share the same show-the-gap guard the legacy summary already had. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/chat/[id]/page.tsx | 2 +- app/(dashboard)/pending/page.tsx | 578 +----------------- components/agent/AgentChat.tsx | 33 +- components/agent/ApprovalCard.tsx | 367 ++--------- .../attach-staged-operations.test.ts | 30 +- .../pending-operations/OperationPreview.tsx | 465 ++++++++++++++ components/pending-operations/vocabulary.ts | 145 +++++ lib/agent/chat/run-turn.ts | 5 + .../__tests__/tool-name.test.ts | 67 ++ lib/pending-operations/tool-name.ts | 29 + types/index.ts | 1 + 12 files changed, 794 insertions(+), 929 deletions(-) create mode 100644 components/pending-operations/OperationPreview.tsx create mode 100644 components/pending-operations/vocabulary.ts create mode 100644 lib/pending-operations/__tests__/tool-name.test.ts create mode 100644 lib/pending-operations/tool-name.ts diff --git a/DECISIONS.md b/DECISIONS.md index 90fdcdfd..6001a80b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -894,6 +894,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-12] Detail pager uses sessionStorage list-context + router.replace, not server neighbor queries: the kundfaktura list order is client-computed and not expressible in PostgREST. [2026-08-12] Automatic logout is opt-in per user (user_preferences.auto_logout, default OFF), reversing the 2026-07 always-on session timeouts: founder decision after a user complaint about multiple daily re-logins. The objection that an opt-in security control is effectively a removed one was raised and overruled; the compromise levers kept are NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true (re-enables enforcement for everyone without a code change) and the opt-in snapshot living inside the signed timeout cookie (no per-request DB read; the preference is read only at mint, and the preferences API clears the cookie on change so a toggle takes effect on the next request). No backstop absolute cap was added: "off" means the Supabase refresh-token lifetime governs, exactly the pre-hardening behavior. Pre-toggle cookies (no autoLogout field) are authentic-but-stale and re-minted preserving their timers, never routed down the tamper path, so the rollout logs nobody out. [2026-08-12] Content dedupe on document ingest is an opt-in uploadDocument flag wired into the intake funnel (uploadAndExtract + mail-hunt ingest), NOT a unique index on (company_id, sha256_hash): archival callers (sent invoices, filings, bank exports) legitimately store repeating bytes and a blanket constraint would break them; the SELECT-then-insert race is accepted exactly as in the WhatsApp precedent. On a hit the funnel adopts the existing inbox item (callers always get a real inbox_item_id) or files an item against the existing document; the mail hunt skips outright since a second item would only duplicate work in Underlag. +[2026-08-12] "One pending-op-owned component" (flows prereq, seam 8.3) is read as one preview + vocabulary + name-mapping (components/pending-operations/OperationPreview + vocabulary, lib/pending-operations/tool-name), NOT one chrome: the duplicated seam was the preview dispatch (three renderings, and ApprovalCard's 4-tool-name switch silently dropped every other hydrated type to raw JSON), while the chromes (list row on /pending vs inline chat card) are deliberately different surfaces, and forcing one over both would be a visual redesign of /pending requiring separate sign-off. [2026-08-12] Inbox "booked" state is derived server-side from the matched transaction (GET /items enrichment) in ADDITION to write-side created_journal_entry_id stamps, not stamps alone: created_journal_entry_id is UNIQUE (migration 20260515090000), so on a bulk-book samlingsverifikat only one of N matched items can ever carry the stamp; a stamp-only fix could not clear the reported flood of matched items on bulk-booked transactions. The constraint stays (it guards the book-direct double-fire race); the stamp becomes a fast path and the derivation the source of truth. [2026-08-13] Pulled archive/connectfile out of Fortnox DEFAULT_SCOPES (reverting the #1541 addition) instead of enabling them in the Fortnox Developer Portal: requesting a scope the registered app lacks makes Fortnox reject authorize with invalid_scope before login, which broke EVERY Fortnox connect in prod within minutes of the #1541 deploy (verified in Vercel logs, user willemduplessis999/TETTET). The attachment-import feature itself stays: it already degrades via 403 -> PROVIDER_DOCUMENT_SCOPES_REQUIRED with a reconnect follow-up. Re-add the scopes only after the portal registration has them approved. [2026-08-13] ChannelQuestionAsked/Answered/Expired registered in processing_event_types rather than dropping the appends: appendQuestionHistory swallows the FK violation by design so the WhatsApp reply still goes out, which turned a missing catalog row into a per-question production error nobody saw, and the question exchange is part of how the underlag was obtained (BFNAR 2013:2 kap 8). diff --git a/app/(dashboard)/chat/[id]/page.tsx b/app/(dashboard)/chat/[id]/page.tsx index 4559cb57..4510194e 100644 --- a/app/(dashboard)/chat/[id]/page.tsx +++ b/app/(dashboard)/chat/[id]/page.tsx @@ -42,7 +42,7 @@ export default async function ChatConversationPage({ params }: PageProps) { // its expiry in Granskning with nothing here pointing at it. supabase .from('pending_operations') - .select('id, operation_type, title, risk_level, preview_data, created_at') + .select('id, operation_type, title, risk_level, preview_data, params, created_at') .eq('company_id', companyId) .eq('status', 'pending') .eq('agent_metadata->>conversation_id', id) diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index c04b315e..d37a4a64 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback, useMemo, Fragment, createContext, useContext } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { DataListEmpty, DataListLoading } from '@/components/ui/data-list' import { ContextPicker } from '@/components/common/ContextPicker' -import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS, VTH_CLASS, VTD_CLASS } from '@/components/ui/dry-table' +import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' import { SlideOver, SlideOverContent, @@ -29,7 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Textarea } from '@/components/ui/textarea' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' -import { cn, formatCurrency, formatDate } from '@/lib/utils' +import { cn, formatDate } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' import { createClient } from '@/lib/supabase/client' import { useCompanyOptional } from '@/contexts/CompanyContext' @@ -50,94 +50,12 @@ import type { PendingOperation, PendingOperationRejectionCategory, } from '@/types' -import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview' -import { MatchTransactionInvoicePreview } from '@/components/bookkeeping/MatchTransactionInvoicePreview' - -// Short human label (i18n key in the "pending" namespace) for each staged -// operation_type. Keep in sync with OPERATION_RISK_TIERS in -// lib/pending-operations/risk-tiers.ts: every operation an agent can stage -// needs a label here, otherwise the Granskning list falls back to the raw -// snake_case tool name (e.g. "create_supplier_invoice_from_inbox"), which is -// long and pushes the meta row to wrap awkwardly on mobile. -const OPERATION_LABEL_KEYS: Record = { - categorize_transaction: 'type_categorize_transaction', - create_customer: 'type_create_customer', - create_invoice: 'type_create_invoice', - create_transaction: 'type_create_transaction', - create_voucher: 'type_create_voucher', - correct_entry: 'type_correct_entry', - reverse_entry: 'type_reverse_entry', - mark_invoice_paid: 'type_mark_invoice_paid', - send_invoice: 'type_send_invoice', - mark_invoice_sent: 'type_mark_invoice_sent', - match_transaction_invoice: 'type_match_transaction_invoice', - // Master data - create_supplier: 'type_create_supplier', - create_article: 'type_create_article', - update_article: 'type_update_article', - create_account: 'type_create_account', - update_account: 'type_update_account', - create_dimension_value: 'type_create_dimension_value', - // Supplier invoices - create_supplier_invoice_from_inbox: 'type_create_supplier_invoice_from_inbox', - create_self_billed_supplier_invoice: 'type_create_self_billed_supplier_invoice', - approve_supplier_invoice: 'type_approve_supplier_invoice', - credit_supplier_invoice: 'type_credit_supplier_invoice', - // Invoices - credit_invoice: 'type_credit_invoice', - convert_invoice: 'type_convert_invoice', - // Documents & links - attach_document_to_transaction: 'type_attach_document_to_transaction', - link_document_to_voucher: 'type_link_document_to_voucher', - link_invoice_voucher: 'type_link_invoice_voucher', - link_supplier_invoice_voucher: 'type_link_supplier_invoice_voucher', - link_transaction_journal_entry: 'type_link_transaction_journal_entry', - uncategorize_transaction: 'type_uncategorize_transaction', - retag_line_dimensions: 'type_retag_line_dimensions', - set_voucher_note: 'type_set_voucher_note', - // Bulk booking / allocation - match_batch_allocate: 'type_match_batch_allocate', - bulk_book_transactions: 'type_bulk_book_transactions', - bulk_book_inbox_items: 'type_bulk_book_inbox_items', - // Periods, year-end, depreciation - close_period: 'type_close_period', - lock_period: 'type_lock_period', - unlock_period: 'type_unlock_period', - set_opening_balances: 'type_set_opening_balances', - run_year_end: 'type_run_year_end', - run_currency_revaluation: 'type_run_currency_revaluation', - post_annual_depreciation: 'type_post_annual_depreciation', - explain_voucher_gap: 'type_explain_voucher_gap', - // SIE - import_sie: 'type_import_sie', - undo_sie_import: 'type_undo_sie_import', - // Payroll & Skatteverket filings - create_salary_run: 'type_create_salary_run', - book_salary_run: 'type_book_salary_run', - generate_agi: 'type_generate_agi', - update_payslip_line: 'type_update_payslip_line', - register_absence: 'type_register_absence', - delete_absence: 'type_delete_absence', - create_employee: 'type_create_employee', - update_employee: 'type_update_employee', - set_employee_opening_balances: 'type_set_employee_opening_balances', - vacation_year_close: 'type_vacation_year_close', - submit_vat_declaration: 'type_submit_vat_declaration', - submit_agi: 'type_submit_agi', -} - -// Fallback for an operation_type with no entry above (e.g. a newly added op -// not yet given a label): turn "create_supplier_invoice_from_inbox" into -// "Create supplier invoice from inbox" so it never surfaces as raw snake_case. -function humanizeOperationType(operationType: string): string { - const spaced = operationType.replace(/_/g, ' ') - return spaced.charAt(0).toUpperCase() + spaced.slice(1) -} - -function operationLabel(operationType: string, t: (key: string) => string): string { - const labelKey = OPERATION_LABEL_KEYS[operationType] - return labelKey ? t(labelKey) : humanizeOperationType(operationType) -} +import { OperationPreview, AccountNamesContext } from '@/components/pending-operations/OperationPreview' +import { + operationLabel, + singleActionWarning, + REJECTION_CATEGORY_LABELS, +} from '@/components/pending-operations/vocabulary' // Terse per-type labels used in the bulk confirmation dialog list. Phrased so // they read naturally under the heading "Genom att bekräfta utförs följande:". @@ -163,47 +81,6 @@ function bulkActionLabel(operationType: string, count: number, t: (key: string) return `${count} × ${operationLabel(operationType, t)}` } -// Full-sentence warning for the single-op confirmation dialog AND the inline -// list-view warning when risk is medium/high. The list-view truncates beyond -// one line; the dialog shows it in full. Order roughly low → high risk so -// reviewers scanning the source see the destructive paths grouped together. -const singleActionWarnings: Record = { - // Low/medium risk: light verifikation work - create_transaction: 'Genom att klicka godkänn så skapar du en transaktion.', - create_customer: 'Genom att klicka godkänn så skapar du en kund.', - create_invoice: 'Genom att klicka godkänn så skapas ett fakturautkast (det skickas inte).', - categorize_transaction: 'Genom att klicka godkänn så kategoriseras transaktionen och en verifikation skapas.', - match_transaction_invoice: 'Genom att klicka godkänn så matchas transaktionen mot fakturan.', - attach_document_to_transaction: 'Genom att klicka godkänn så bifogas dokumentet till transaktionen.', - uncategorize_transaction: 'Genom att klicka godkänn så tas kategoriseringen bort.', - send_invoice: 'Genom att klicka godkänn så skickas fakturan till kunden.', - mark_invoice_paid: 'Genom att klicka godkänn så bokförs en betalning på fakturan.', - mark_invoice_sent: 'Genom att klicka godkänn så märks fakturan som skickad och en verifikation skapas.', - // High risk: period/year-end/voucher edits. These are the ones the reviewer - // really needs the warning for, so we keep them concrete: name the - // irreversibility or compliance consequence, not the generic risk-level. - lock_period: 'Genom att klicka godkänn så låses perioden: inga nya verifikationer kan bokföras tills den låses upp.', - unlock_period: 'Genom att klicka godkänn så låses perioden upp. Använd endast för rättelser; lås igen efter.', - close_period: 'Genom att klicka godkänn så stängs perioden permanent (BFL). Stängningen kan inte ångras.', - run_year_end: 'Genom att klicka godkänn så körs bokslut: resultatkonton nollställs, perioden låses, nästa period skapas.', - set_opening_balances: 'Genom att klicka godkänn så bokförs ingående balans i nästa period.', - run_currency_revaluation: 'Genom att klicka godkänn så bokförs valutaomvärdering (3960/7960).', - create_voucher: 'Genom att klicka godkänn så bokförs verifikationen med ett nytt löpnummer.', - correct_entry: 'Genom att klicka godkänn så stornas originalverifikationen och en rättelse bokförs (BFL 5 kap 5§).', - reverse_entry: 'Genom att klicka godkänn så stornas verifikationen: originalet behålls synligt (BFL 5 kap).', - credit_invoice: 'Genom att klicka godkänn så skapas en kreditfaktura och originalverifikationen stornas.', - credit_supplier_invoice: 'Genom att klicka godkänn så krediteras leverantörsfakturan och registreringsverifikationen stornas.', - approve_supplier_invoice: 'Genom att klicka godkänn så attesteras leverantörsfakturan och blir betalningsbar.', - convert_invoice: 'Genom att klicka godkänn så konverteras proformafakturan till en riktig faktura med F-nummer.', - import_sie: 'Genom att klicka godkänn så importeras SIE-filen: räkenskapsperiod, ingående balans och verifikationer skapas.', - explain_voucher_gap: 'Genom att klicka godkänn så dokumenteras förklaringen för verifikationsluckan (BFNAR 2013:2).', - post_annual_depreciation: 'Genom att klicka godkänn så bokförs planenlig avskrivning: en verifikation per tillgång.', -} - -function singleActionWarning(operationType: string): string { - return singleActionWarnings[operationType] ?? '' -} - // Period status carried inside preview_data when stagePendingOperation can // resolve it. Shape mirrors PeriodStatusForDate in lib/core/bookkeeping/period-service.ts. interface PeriodStatusShape { @@ -235,14 +112,6 @@ const GACT_NO_CLASS = 'border-destructive/40 text-destructive hover:bg-destructi const GACT_NEUTRAL_CLASS = 'border-border text-muted-foreground hover:bg-secondary/40 hover:text-foreground' -const REJECTION_CATEGORY_LABELS: Record = { - wrong_category: 'Fel kategori / konto', - wrong_amount: 'Fel belopp', - duplicate: 'Dubblett', - wrong_period: 'Fel period', - other: 'Annat', -} - /** * Human origin line for a staged operation. Many reviewers never used the AI * chat themselves (a colleague or consultant did), so the raw actor_label is @@ -321,8 +190,6 @@ function formatRelativeTime(dateStr: string): string { * names after a switch. A failed fetch leaves the map empty, which shows the * bare number rather than a wrong name, and retries on the next mount. */ -const AccountNamesContext = createContext>({}) - function useAccountNamesSource(): Record { const [names, setNames] = useState>({}) useEffect(() => { @@ -352,433 +219,6 @@ function useAccountNamesSource(): Record { } -function CategorizePreview({ data }: { data: Record }) { - const accountNames = useContext(AccountNamesContext) - // The exact journal lines the approval will post (net cost line, VAT line, - // gross bank line, SEK) — staged by the server since the preview-lines fix. - const lines = (data.lines as Array<{ account_number?: string; debit_amount?: number; credit_amount?: number; description?: string }>) || [] - const vatLines = (data.vat_lines as Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>) || [] - - if (lines.length > 0) { - return ( -
-

Verifikat

- {lines.map((line, i) => { - const debitAmt = typeof line.debit_amount === 'number' ? line.debit_amount : 0 - const creditAmt = typeof line.credit_amount === 'number' ? line.credit_amount : 0 - return ( -
- - {line.account_number ?? '?'}{' '} - {/* The account's own name first: it is what the posting means. - The line text follows only when it adds something the name - does not already say. */} - - {(line.account_number && accountNames[line.account_number]) || line.description || ''} - - {line.description && - line.account_number && - accountNames[line.account_number] && - line.description !== accountNames[line.account_number] ? ( - · {line.description} - ) : null} - - - {debitAmt > 0 ? `D ${formatCurrency(debitAmt)}` : `K ${formatCurrency(creditAmt)}`} - -
- ) - })} -
- ) - } - - // Some operations carry their kontering under the generic `preview_lines` - // key instead (the shape every other staged type renders through). Read it - // before falling through to the legacy summary, which would otherwise show - // blank accounts for a preview that does describe the entry in full. - if (isKonteringLines(data.preview_lines)) { - return ( -
-

Verifikat

- -
- ) - } - - // Legacy summary for operations staged before the preview carried full - // lines: debit/credit accounts + gross amount + separate VAT rows. - const legacyAmount = typeof data.amount === 'number' && Number.isFinite(data.amount) - ? data.amount - : null - return ( -
-
- Debetkonto - {String(data.debit_account ?? '')} - Kreditkonto - {String(data.credit_account ?? '')} - Belopp - - {/* A preview with no usable amount used to render "NaN kr": show the - gap as a gap instead of a number that isn't one. */} - {legacyAmount === null - ? '-' - : formatCurrency(legacyAmount, (data.currency as string) || 'SEK')} - -
- {vatLines.length > 0 && ( -
-

Momsrader

- {vatLines.map((line, i) => ( -
- - {line.account_number}{' '} - {accountNames[line.account_number] || line.description} - {accountNames[line.account_number] && - line.description !== accountNames[line.account_number] ? ( - · {line.description} - ) : null} - - - {line.debit_amount > 0 ? `D ${formatCurrency(line.debit_amount)}` : `K ${formatCurrency(line.credit_amount)}`} - -
- ))} -
- )} -
- ) -} - -function CustomerPreview({ data }: { data: Record }) { - return ( -
- Namn - {String(data.name ?? '')} - Typ - {String(data.customer_type ?? '')} - {data.email ? ( - <> - E-post - {String(data.email)} - - ) : null} - {data.org_number ? ( - <> - Org.nr - {String(data.org_number)} - - ) : null} -
- ) -} - -function InvoicePreview({ data }: { data: Record }) { - const items = (data.items as Array<{ description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate: number }>) || [] - - return ( -
-
- Kund - {String(data.customer_name ?? '')} - Datum - {String(data.invoice_date ?? '')} - Förfallodatum - {String(data.due_date ?? '')} -
- {items.length > 0 && ( -
- {items.map((item, i) => ( -
- {item.description} ({item.quantity} {item.unit}) - - {formatCurrency(item.line_total, (data.currency as string) || 'SEK')} - -
- ))} -
- )} -
- Netto - {formatCurrency(data.subtotal as number, (data.currency as string) || 'SEK')} - Moms - {formatCurrency(data.vat_amount as number, (data.currency as string) || 'SEK')} - Totalt - {formatCurrency(data.total as number, (data.currency as string) || 'SEK')} -
-
- ) -} - -function CreateTransactionPreview({ data }: { data: Record }) { - const amount = data.amount as number - const currency = (data.currency as string) || 'SEK' - - return ( -
- Datum - {String(data.date ?? '')} - Beskrivning - {String(data.description ?? '')} - Belopp - - {formatCurrency(amount, currency)} - - {data.external_id ? ( - <> - Extern referens - {String(data.external_id)} - - ) : null} -
- ) -} - -type VoucherLine = { - account_number: string - account_name?: string | null - debit_amount: number - credit_amount: number - line_description?: string | null -} - -function VoucherLinesTable({ lines, currency }: { lines: VoucherLine[]; currency?: string }) { - return ( -
- {lines.map((line, i) => ( -
- {line.account_number} - - {line.account_name || line.line_description || '-'} - - - {line.debit_amount > 0 ? formatCurrency(line.debit_amount, currency || 'SEK') : ''} - - - {line.credit_amount > 0 ? formatCurrency(line.credit_amount, currency || 'SEK') : ''} - -
- ))} -
- ) -} - -function VoucherPreview({ data }: { data: Record }) { - const lines = (data.lines as VoucherLine[]) || [] - const totalDebit = data.total_debit as number | undefined - const totalCredit = data.total_credit as number | undefined - - return ( -
-
- Datum - {String(data.entry_date ?? '')} - Beskrivning - {String(data.description ?? '')} - Serie - {String(data.voucher_series ?? 'A')} -
- {lines.length > 0 && ( -
-
- Konto - Text - Debet - Kredit -
- -
- )} - {totalDebit != null && totalCredit != null && ( -
- - Summa - - {formatCurrency(totalDebit)} - - - {formatCurrency(totalCredit)} - -
- )} -
- ) -} - -function CorrectEntryPreview({ data }: { data: Record }) { - const original = (data.original as { - voucher?: string - entry_date?: string - description?: string - lines?: VoucherLine[] - }) || {} - const correction = (data.correction as { - total_debit?: number - total_credit?: number - line_count?: number - lines?: VoucherLine[] - }) || {} - - return ( -
-
-

- Originalverifikation V{original.voucher ?? ''}, {original.entry_date ?? ''} -

-

{original.description ?? ''}

- {original.lines && original.lines.length > 0 && ( - - )} -
-
-

- Korrigerad verifikation ({correction.line_count ?? correction.lines?.length ?? 0} rader) -

- {correction.lines && correction.lines.length > 0 && ( - - )} - {correction.total_debit != null && ( -
- - Summa - - {formatCurrency(correction.total_debit)} - - - {formatCurrency(correction.total_credit ?? 0)} - -
- )} -
-
- ) -} - -// Render a primitive (string/number/bool) or a short summary of an array/object. -// Used by GenericPreview to avoid the "[object Object]" stringification that -// occurs when an operation_type has no dedicated preview component. -function renderPrimitive(value: unknown): string { - if (value == null) return '' - if (Array.isArray(value)) return `${value.length} rader` - if (typeof value === 'object') return JSON.stringify(value) - return String(value) -} - -// A preview_data value that is a kontering (array of account/debit/credit -// rows). Several staged op types carry one under keys like `preview_lines` -// without a dedicated preview component; rendering it as the actual -// verifikat rows is what makes the detail panel say what the agent will do. -interface PreviewKonteringLine { - account?: string - account_number?: string - description?: string - debit?: number - credit?: number - debit_amount?: number - credit_amount?: number -} - -function isKonteringLines(value: unknown): value is PreviewKonteringLine[] { - return ( - Array.isArray(value) && - value.length > 0 && - value.every( - (line) => - line != null && - typeof line === 'object' && - ('account' in line || 'account_number' in line) && - ('debit' in line || 'credit' in line || 'debit_amount' in line || 'credit_amount' in line), - ) - ) -} - -function PreviewKonteringTable({ lines }: { lines: PreviewKonteringLine[] }) { - const amount = (n: number | undefined) => - n && n > 0 ? n.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : '' - return ( - - - - - - - - - - - {lines.map((line, i) => ( - - - - - - - ))} - -
KontoBeskrivningDebetKredit
- {line.account ?? line.account_number} - {line.description ?? ''} - {amount(line.debit ?? line.debit_amount)} - - {amount(line.credit ?? line.credit_amount)} -
- ) -} - -function GenericPreview({ data }: { data: Record }) { - // Skip period_status here: it's surfaced in the dedicated banner, not the - // generic key-value dump (otherwise the approver sees the same fact twice). - const entries = Object.entries(data).filter(([k, v]) => v != null && v !== '' && k !== 'period_status') - const konteringEntries = entries.filter(([, v]) => isKonteringLines(v)) - const rest = entries.filter(([, v]) => !isKonteringLines(v)) - return ( -
- {konteringEntries.map(([key, value]) => ( - - ))} - {rest.length > 0 && ( -
- {rest.map(([key, value]) => ( - - {key.replace(/_/g, ' ')} - - {renderPrimitive(value)} - - - ))} -
- )} -
- ) -} - -function OperationPreview({ op }: { op: PendingOperation }) { - const body = (() => { - switch (op.operation_type) { - case 'categorize_transaction': - return - case 'create_customer': - return - case 'create_invoice': - return - case 'create_transaction': - return - case 'create_voucher': - return - case 'correct_entry': - return - case 'attach_document_to_transaction': - return - case 'match_transaction_invoice': - return - default: - return - } - })() - return body -} - /** * Inline period-lock banner. Renders when the staged operation touches a * period that's already locked or closed: the server's commit-time trigger diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx index 4bd95a4c..9434dbee 100644 --- a/components/agent/AgentChat.tsx +++ b/components/agent/AgentChat.tsx @@ -145,9 +145,16 @@ interface StagedOperation { operation_id?: string risk_level: 'low' | 'medium' | 'high' message: string - // The originating tool name (e.g. 'gnubok_categorize_transaction'). Lets - // ApprovalCard pick the right structured-preview renderer. + // The originating tool name (e.g. 'gnubok_categorize_transaction'), as + // carried by live staged_operation stream events. tool_name?: string + // The stored pending_operations.operation_type (e.g. + // 'categorize_transaction'), set on hydrated cards. ApprovalCard prefers + // this for preview dispatch and derives it from tool_name otherwise. + operation_type?: string + // pending_operations.params (the staging tool's input): some previews + // read it (attach_document's DocumentViewButton needs params.document_id). + params?: Record // The structured operation preview from the staged envelope. Shape varies // by tool; ApprovalCard's renderers do the type-narrowing. preview?: unknown @@ -686,6 +693,7 @@ export default function AgentChat({ { tool_use_id: ev.tool_use_id as string, tool_name: (ev.tool_name as string | undefined) ?? undefined, + params: (ev.params as Record | undefined) ?? undefined, operation_id: stagedRaw.operation_id, risk_level: stagedRaw.risk_level, message: stagedRaw.message, @@ -999,6 +1007,8 @@ function MessageBubble({ riskLevel={s.risk_level} message={s.message} toolName={s.tool_name} + operationType={s.operation_type} + params={s.params} preview={s.preview} periodStatus={s.period_status} onRequestCorrection={onCorrection} @@ -1288,18 +1298,6 @@ function prettyToolName(name: string): string { * on the last assistant message so they read as that turn's proposal, which is * where they were when the turn streamed. */ -/** - * `pending_operations.operation_type` stores the bare action name - * ('categorize_transaction'), while the live streamed card carries the MCP tool - * name ('gnubok_categorize_transaction') and ApprovalCard's PreviewBlock - * dispatches on that. Without this, every hydrated card fell through to the - * flat generic preview instead of the journal-line one, so a resumed proposal - * looked materially worse than the same proposal did live. - */ -export function toolNameFor(operationType: string): string { - return operationType.startsWith('gnubok_') ? operationType : `gnubok_${operationType}` -} - export function attachStagedOperations( messages: ChatMessage[], staged: StoredStagedOperation[], @@ -1314,7 +1312,12 @@ export function attachStagedOperations( risk_level: op.risk_level === 'high' || op.risk_level === 'medium' ? op.risk_level : 'low', message: op.title ?? 'Förslag väntar på granskning.', - tool_name: toolNameFor(op.operation_type), + // The stored bare operation_type drives the preview dispatch directly. + // Its predecessor mapped it onto an MCP tool name here ('gnubok_' + + // type) for ApprovalCard's old 4-case tool-name switch, so every other + // hydrated type silently lost its specialized preview. + operation_type: op.operation_type, + params: (op.params ?? undefined) as Record | undefined, preview: op.preview_data, })) diff --git a/components/agent/ApprovalCard.tsx b/components/agent/ApprovalCard.tsx index a4f37f18..a5aec1aa 100644 --- a/components/agent/ApprovalCard.tsx +++ b/components/agent/ApprovalCard.tsx @@ -10,8 +10,10 @@ import { useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import type { PendingOperationRejectionCategory } from '@/types' import { cn } from '@/lib/utils' -import { formatCurrency } from '@/lib/utils' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { OperationPreview } from '@/components/pending-operations/OperationPreview' +import { REJECTION_CATEGORY_LABELS } from '@/components/pending-operations/vocabulary' +import { operationTypeFromToolName } from '@/lib/pending-operations/tool-name' // Inline approval card for an agent-staged pending_operation. // @@ -29,9 +31,11 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m // exactly one approval source of record. // // Structured preview: when the staged envelope carries a preview object, we -// render a scannable summary block under the prose. Each common tool has its -// own renderer; unknown tools fall through to a flat key/value list so a new -// tool can ship without an ApprovalCard change. +// render the shared OperationPreview (components/pending-operations), the +// same renderers /pending uses, dispatched on operation_type. Live streamed +// cards carry the MCP tool name; hydrated cards carry the stored +// operation_type directly. Unknown types fall through to the generic +// key/value renderer so a new tool can ship without an ApprovalCard change. interface PeriodStatus { period_id?: string | null @@ -44,6 +48,13 @@ interface Props { riskLevel: 'low' | 'medium' | 'high' message: string toolName?: string + // The stored pending_operations.operation_type. Preferred over deriving it + // from toolName: hydrated cards pass it straight from the DB row, so every + // operation type keeps its specialized preview on resume. + operationType?: string + // pending_operations.params (the staging tool's input). Some previews read + // it: attach_document's DocumentViewButton needs params.document_id. + params?: Record preview?: unknown periodStatus?: PeriodStatus // Fired after a reject that carries a reason: the chat feeds this synthetic @@ -53,17 +64,6 @@ interface Props { type State = 'pending' | 'committing' | 'committed' | 'rejecting' | 'rejected' | 'error' -// Mirrors the granskning (/pending) reject dialog so chat rejections capture -// the same structured feedback. Stored on the op + surfaced to the agent via -// gnubok_get_recent_rejections. -const REJECTION_CATEGORY_LABELS: Record = { - wrong_category: 'Fel kategori / konto', - wrong_amount: 'Fel belopp', - duplicate: 'Dubblett', - wrong_period: 'Fel period', - other: 'Annat', -} - // Subset of fields the commit response may return that the success state // uses to deep-link to the freshly-created artifact. Different // operation_types return different shapes: only the ones we actually @@ -86,6 +86,8 @@ export default function ApprovalCard({ riskLevel, message, toolName, + operationType, + params, preview, periodStatus, onRequestCorrection, @@ -117,6 +119,14 @@ export default function ApprovalCard({ const canCommit = !requiresTextConfirm || confirmText.trim().toLowerCase() === 'godkänn' + // Render key for the shared preview. Hydrated cards pass operation_type + // straight from the pending_operations row; live streamed cards carry the + // MCP tool name, which maps to the bare operation_type by stripping the + // wire prefix. The old dispatch keyed on 4 hardcoded tool names, so every + // other hydrated type silently lost its specialized preview. + const previewOperationType = + operationType ?? (toolName ? operationTypeFromToolName(toolName) : '') + async function handleCommit() { setState('committing') setErrorMessage(null) @@ -341,7 +351,17 @@ export default function ApprovalCard({ {periodStatus && } - + {preview != null && typeof preview === 'object' && ( +
+ , + params, + }} + /> +
+ )} {requiresTextConfirm && (
@@ -489,321 +509,6 @@ export default function ApprovalCard({ ) } -// ─── Structured preview block ────────────────────────────────────────────── -// -// Dispatches on tool_name. Adding a new tool: write a specialized renderer -// here. Falling back to the generic flat list is fine for low-volume tools. - -interface PreviewBlockProps { - toolName?: string - preview?: unknown -} - -function PreviewBlock({ toolName, preview }: PreviewBlockProps) { - if (!preview || typeof preview !== 'object') return null - const p = preview as Record - - if (toolName === 'gnubok_categorize_transaction') { - return - } - if (toolName === 'gnubok_create_invoice') { - return - } - if (toolName === 'gnubok_create_voucher' || toolName === 'gnubok_correct_entry') { - return - } - - return -} - -// 20 categories from types/index.ts TransactionCategory. Kept inline so the -// component has no cross-module enum import; sync if the type changes. -const CATEGORY_OPTIONS: { value: string; label: string }[] = [ - { value: 'income_services', label: 'Intäkt: tjänster' }, - { value: 'income_products', label: 'Intäkt: produkter' }, - { value: 'income_other', label: 'Intäkt: övrigt' }, - { value: 'expense_software', label: 'Kostnad: mjukvara' }, - { value: 'expense_equipment', label: 'Kostnad: utrustning' }, - { value: 'expense_office', label: 'Kostnad: kontor' }, - { value: 'expense_travel', label: 'Kostnad: resor' }, - { value: 'expense_marketing', label: 'Kostnad: marknadsföring' }, - { value: 'expense_professional_services', label: 'Kostnad: konsult/tjänster' }, - { value: 'expense_education', label: 'Kostnad: utbildning' }, - { value: 'expense_representation', label: 'Kostnad: representation' }, - { value: 'expense_consumables', label: 'Kostnad: förbrukning' }, - { value: 'expense_vehicle', label: 'Kostnad: fordon' }, - { value: 'expense_telecom', label: 'Kostnad: telefon/internet' }, - { value: 'expense_bank_fees', label: 'Kostnad: bankavgifter' }, - { value: 'expense_card_fees', label: 'Kostnad: kortavgifter' }, - { value: 'expense_currency_exchange', label: 'Kostnad: valutaväxling' }, - { value: 'expense_other', label: 'Kostnad: övrigt' }, - { value: 'private', label: 'Privat uttag' }, -] - -function CategorizeTransactionPreview({ - preview, -}: { - preview: Record -}) { - const debit = preview.debit_account as string | undefined - const credit = preview.credit_account as string | undefined - const amount = preview.amount as number | undefined - const currency = (preview.currency as string | undefined) ?? 'SEK' - const category = preview.category as string | undefined - // The exact journal lines the approval will post (net cost line, VAT line, - // gross bank line, SEK), staged by the server since the preview-lines fix. - const lines = (preview.lines as - | { - account_number?: string - debit_amount?: number - credit_amount?: number - description?: string - }[] - | undefined) ?? [] - // Legacy summary fields, rendered only for operations staged before the - // preview carried full lines. Pairing the gross amount with the cost - // account reads as an unbalanced entry: never show it when lines exist. - const vatLines = (preview.vat_lines as - | { - account_number?: string - debit_amount?: number - credit_amount?: number - description?: string - }[] - | undefined) ?? [] - - return ( -
-
- - Kategori - - - {prettyCategory(category)} - -
- {lines.length > 0 ? ( -
- {lines.map((l, i) => { - const debitAmt = typeof l.debit_amount === 'number' ? l.debit_amount : 0 - const creditAmt = typeof l.credit_amount === 'number' ? l.credit_amount : 0 - const side: 'D' | 'K' = debitAmt > 0 ? 'D' : 'K' - return ( - - {side} - {l.account_number ?? '?'} - - {formatCurrency(side === 'D' ? debitAmt : creditAmt)} - - - } - /> - ) - })} -
- ) : ( - <> - {debit && credit && amount != null && ( - - D - {debit} - / K - {credit} - {formatCurrency(amount, currency)} - - } - /> - )} - {vatLines.length > 0 && ( -
- {vatLines.map((v, i) => { - const debit = typeof v.debit_amount === 'number' ? v.debit_amount : 0 - const credit = typeof v.credit_amount === 'number' ? v.credit_amount : 0 - const side: 'D' | 'K' | null = debit > 0 ? 'D' : credit > 0 ? 'K' : null - const amount = side === 'D' ? debit : side === 'K' ? credit : 0 - return ( - - {side && {side} } - {v.account_number ?? ''} - {formatCurrency(amount, currency)} - - } - /> - ) - })} -
- )} - - )} -
- ) -} - -// Pull a human message out of an API error body that may be either a bare -// string ({ error: "…" }) or the structured envelope ({ error: { message } }). -function prettyCategory(value: string | undefined): string { - if (!value) return '(saknas)' - return CATEGORY_OPTIONS.find((o) => o.value === value)?.label ?? value -} - -function CreateInvoicePreview({ preview }: { preview: Record }) { - const customer = preview.customer_name as string | undefined - const subtotal = preview.subtotal as number | undefined - const vatAmount = preview.vat_amount as number | undefined - const total = preview.total as number | undefined - const currency = (preview.currency as string | undefined) ?? 'SEK' - const items = - (preview.items as { description?: string; line_total?: number }[] | undefined) ?? [] - - return ( -
- {customer && ( - {customer}} /> - )} - {items.length > 0 && ( -
- {items.slice(0, 5).map((it, i) => ( - - - {it.description ?? '(rad)'} - - {it.line_total != null && ( - {formatCurrency(it.line_total, currency)} - )} - - } - /> - ))} - {items.length > 5 && ( -

- + {items.length - 5} ytterligare rader -

- )} -
- )} -
- {subtotal != null && ( - {formatCurrency(subtotal, currency)} - } - /> - )} - {vatAmount != null && ( - {formatCurrency(vatAmount, currency)} - } - /> - )} - {total != null && ( - - {formatCurrency(total, currency)} - - } - /> - )} -
-
- ) -} - -function VoucherPreview({ preview }: { preview: Record }) { - const lines = (preview.lines as { account?: string; debit?: number; credit?: number; description?: string }[] | undefined) ?? [] - const date = preview.date as string | undefined - const description = preview.description as string | undefined - - if (lines.length === 0) return - - return ( -
- {date && {date}} />} - {description && ( - {description}} /> - )} -
- {lines.map((l, i) => ( - - {l.account ?? '?'} - · - {l.debit != null && l.debit !== 0 && D {formatCurrency(l.debit)}} - {l.credit != null && l.credit !== 0 && K {formatCurrency(l.credit)}} - {l.description && ( - {l.description} - )} - - } - /> - ))} -
-
- ) -} - -// Fallback: render the top-level key/value pairs from any preview object. -// Strips internal-looking keys, formats numbers tabular, truncates long -// strings. Caps at 8 rows to keep the card compact. -function GenericPreview({ preview }: { preview: Record }) { - const rows: { key: string; value: string }[] = [] - for (const [k, v] of Object.entries(preview)) { - if (rows.length >= 8) break - if (k.startsWith('_') || k === 'period_status') continue - if (v == null) continue - if (typeof v === 'object') continue - rows.push({ key: prettyKey(k), value: String(v) }) - } - if (rows.length === 0) return null - return ( -
- {rows.map((r) => ( - {r.value}} /> - ))} -
- ) -} - -function Row({ label, value }: { label: string; value: React.ReactNode }) { - return ( -
- - {label} - - {value} -
- ) -} - -function prettyKey(k: string): string { - // 'customer_name' → 'Customer name' → keep Swedish-leaning by capitalising - // first letter only; lots of preview keys are already short. - const spaced = k.replace(/_/g, ' ') - return spaced.charAt(0).toUpperCase() + spaced.slice(1) -} - function PeriodBadge({ status }: { status: PeriodStatus }) { if (status.status === 'open') { return ( diff --git a/components/agent/__tests__/attach-staged-operations.test.ts b/components/agent/__tests__/attach-staged-operations.test.ts index c4582db0..805faf8a 100644 --- a/components/agent/__tests__/attach-staged-operations.test.ts +++ b/components/agent/__tests__/attach-staged-operations.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { attachStagedOperations, toolNameFor } from '../AgentChat' +import { attachStagedOperations } from '../AgentChat' import type { StoredStagedOperation } from '@/types' /** @@ -43,31 +43,35 @@ describe('attachStagedOperations', () => { expect(assistant!.staged![0]).toMatchObject({ operation_id: 'op-1', risk_level: 'low', - tool_name: 'gnubok_categorize_transaction', + operation_type: 'categorize_transaction', message: 'Kontering: Circle K, 689 kr', }) }) - it('maps the stored operation_type onto the tool name the preview dispatches on', () => { - // pending_operations stores the bare action name; the live card carries the - // MCP tool name, and ApprovalCard's PreviewBlock keys off that. Getting this - // wrong is invisible in a mock but drops every hydrated card to the flat - // generic preview instead of the journal-line one. These four are the - // operation types that have a specialized renderer. + it('passes the stored operation_type through untranslated, for EVERY type', () => { + // The predecessor mapped operation_type onto an MCP tool name here and + // ApprovalCard's old preview dispatch only recognized 4 of them, so a + // hydrated attach_document_to_transaction card (among others) silently + // fell back to the raw generic preview. The stored bare name now drives + // the dispatch directly; nothing may rewrite it on the way. for (const t of [ 'categorize_transaction', 'create_invoice', 'create_voucher', 'correct_entry', + 'attach_document_to_transaction', + 'match_transaction_invoice', + 'create_customer', + 'create_transaction', ]) { - expect(toolNameFor(t)).toBe(`gnubok_${t}`) + const out = attachStagedOperations( + [{ role: 'assistant', text: 'svar' }], + [op({ operation_type: t })], + ) + expect(out[0]!.staged![0]!.operation_type).toBe(t) } }) - it('leaves an already-prefixed operation type alone', () => { - expect(toolNameFor('gnubok_create_voucher')).toBe('gnubok_create_voucher') - }) - it('keeps risk level for medium and high, and floors anything unknown to low', () => { const messages = [{ role: 'assistant' as const, text: 'svar' }] diff --git a/components/pending-operations/OperationPreview.tsx b/components/pending-operations/OperationPreview.tsx new file mode 100644 index 00000000..bd483bb8 --- /dev/null +++ b/components/pending-operations/OperationPreview.tsx @@ -0,0 +1,465 @@ +'use client' + +// The one pending-op-owned preview (flows prereq, seam 8.3): renders what a +// staged pending_operation will do, dispatched on operation_type. Consumed by +// /pending (list detail + confirm dialogs), the chat ApprovalCard, and future +// flow-run views. Renderers moved verbatim from app/(dashboard)/pending/page.tsx; +// keep markup and classNames in lockstep with the design system, not with any +// one consumer. + +import { Fragment, createContext, useContext } from 'react' +import { cn, formatCurrency } from '@/lib/utils' +import { VTH_CLASS, VTD_CLASS } from '@/components/ui/dry-table' +import type { PendingOperation } from '@/types' +import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview' +import { MatchTransactionInvoicePreview } from '@/components/bookkeeping/MatchTransactionInvoicePreview' + +// The subset of a PendingOperation the preview actually reads. operation_type +// is widened to string: the chat surface derives it from an MCP tool name and +// unknown values legitimately fall through to GenericPreview. params carries +// tool inputs some renderers need (e.g. attach_document_to_transaction's +// document_id); surfaces that only have preview_data may omit it. +export interface OperationPreviewInput { + operation_type: string + preview_data: PendingOperation['preview_data'] + params?: PendingOperation['params'] +} + +/** + * Account number -> account name, for the proposal previews. The value is + * provided by whichever page owns the fetch lifecycle (/pending provides a + * per-mount, per-company map; a consumer that provides nothing gets the + * default {} and previews show the bare number, never a wrong name). + */ +export const AccountNamesContext = createContext>({}) + +/** Render '-' instead of "NaN kr" when a preview payload omits an amount. */ +function money(v: unknown, currency: string): string { + return typeof v === 'number' && Number.isFinite(v) ? formatCurrency(v, currency) : '-' +} + +function CategorizePreview({ data }: { data: Record }) { + const accountNames = useContext(AccountNamesContext) + // The exact journal lines the approval will post (net cost line, VAT line, + // gross bank line, SEK): staged by the server since the preview-lines fix. + const lines = (data.lines as Array<{ account_number?: string; debit_amount?: number; credit_amount?: number; description?: string }>) || [] + const vatLines = (data.vat_lines as Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>) || [] + + if (lines.length > 0) { + return ( +
+

Verifikat

+ {lines.map((line, i) => { + const debitAmt = typeof line.debit_amount === 'number' ? line.debit_amount : 0 + const creditAmt = typeof line.credit_amount === 'number' ? line.credit_amount : 0 + return ( +
+ + {line.account_number ?? '?'}{' '} + {/* The account's own name first: it is what the posting means. + The line text follows only when it adds something the name + does not already say. */} + + {(line.account_number && accountNames[line.account_number]) || line.description || ''} + + {line.description && + line.account_number && + accountNames[line.account_number] && + line.description !== accountNames[line.account_number] ? ( + · {line.description} + ) : null} + + + {debitAmt > 0 ? `D ${formatCurrency(debitAmt)}` : `K ${formatCurrency(creditAmt)}`} + +
+ ) + })} +
+ ) + } + + // Some operations carry their kontering under the generic `preview_lines` + // key instead (the shape every other staged type renders through). Read it + // before falling through to the legacy summary, which would otherwise show + // blank accounts for a preview that does describe the entry in full. + if (isKonteringLines(data.preview_lines)) { + return ( +
+

Verifikat

+ +
+ ) + } + + // Legacy summary for operations staged before the preview carried full + // lines: debit/credit accounts + gross amount + separate VAT rows. + const legacyAmount = typeof data.amount === 'number' && Number.isFinite(data.amount) + ? data.amount + : null + return ( +
+
+ Debetkonto + {String(data.debit_account ?? '')} + Kreditkonto + {String(data.credit_account ?? '')} + Belopp + + {/* A preview with no usable amount used to render "NaN kr": show the + gap as a gap instead of a number that isn't one. */} + {legacyAmount === null + ? '-' + : formatCurrency(legacyAmount, (data.currency as string) || 'SEK')} + +
+ {vatLines.length > 0 && ( +
+

Momsrader

+ {vatLines.map((line, i) => ( +
+ + {line.account_number}{' '} + {accountNames[line.account_number] || line.description} + {accountNames[line.account_number] && + line.description !== accountNames[line.account_number] ? ( + · {line.description} + ) : null} + + + {line.debit_amount > 0 ? `D ${formatCurrency(line.debit_amount)}` : `K ${formatCurrency(line.credit_amount)}`} + +
+ ))} +
+ )} +
+ ) +} + +function CustomerPreview({ data }: { data: Record }) { + return ( +
+ Namn + {String(data.name ?? '')} + Typ + {String(data.customer_type ?? '')} + {data.email ? ( + <> + E-post + {String(data.email)} + + ) : null} + {data.org_number ? ( + <> + Org.nr + {String(data.org_number)} + + ) : null} +
+ ) +} + +function InvoicePreview({ data }: { data: Record }) { + const items = (data.items as Array<{ description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate: number }>) || [] + + return ( +
+
+ Kund + {String(data.customer_name ?? '')} + Datum + {String(data.invoice_date ?? '')} + Förfallodatum + {String(data.due_date ?? '')} +
+ {items.length > 0 && ( +
+ {items.map((item, i) => ( +
+ {item.description} ({item.quantity} {item.unit}) + + {formatCurrency(item.line_total, (data.currency as string) || 'SEK')} + +
+ ))} +
+ )} +
+ Netto + {money(data.subtotal, (data.currency as string) || 'SEK')} + Moms + {money(data.vat_amount, (data.currency as string) || 'SEK')} + Totalt + {money(data.total, (data.currency as string) || 'SEK')} +
+
+ ) +} + +function CreateTransactionPreview({ data }: { data: Record }) { + const currency = (data.currency as string) || 'SEK' + + return ( +
+ Datum + {String(data.date ?? '')} + Beskrivning + {String(data.description ?? '')} + Belopp + + {money(data.amount, currency)} + + {data.external_id ? ( + <> + Extern referens + {String(data.external_id)} + + ) : null} +
+ ) +} + +type VoucherLine = { + account_number: string + account_name?: string | null + debit_amount: number + credit_amount: number + line_description?: string | null +} + +function VoucherLinesTable({ lines, currency }: { lines: VoucherLine[]; currency?: string }) { + return ( +
+ {lines.map((line, i) => ( +
+ {line.account_number} + + {line.account_name || line.line_description || '-'} + + + {line.debit_amount > 0 ? formatCurrency(line.debit_amount, currency || 'SEK') : ''} + + + {line.credit_amount > 0 ? formatCurrency(line.credit_amount, currency || 'SEK') : ''} + +
+ ))} +
+ ) +} + +function VoucherPreview({ data }: { data: Record }) { + const lines = (data.lines as VoucherLine[]) || [] + const totalDebit = data.total_debit as number | undefined + const totalCredit = data.total_credit as number | undefined + + return ( +
+
+ Datum + {String(data.entry_date ?? '')} + Beskrivning + {String(data.description ?? '')} + Serie + {String(data.voucher_series ?? 'A')} +
+ {lines.length > 0 && ( +
+
+ Konto + Text + Debet + Kredit +
+ +
+ )} + {totalDebit != null && totalCredit != null && ( +
+ + Summa + + {formatCurrency(totalDebit)} + + + {formatCurrency(totalCredit)} + +
+ )} +
+ ) +} + +function CorrectEntryPreview({ data }: { data: Record }) { + const original = (data.original as { + voucher?: string + entry_date?: string + description?: string + lines?: VoucherLine[] + }) || {} + const correction = (data.correction as { + total_debit?: number + total_credit?: number + line_count?: number + lines?: VoucherLine[] + }) || {} + + return ( +
+
+

+ Originalverifikation V{original.voucher ?? ''}, {original.entry_date ?? ''} +

+

{original.description ?? ''}

+ {original.lines && original.lines.length > 0 && ( + + )} +
+
+

+ Korrigerad verifikation ({correction.line_count ?? correction.lines?.length ?? 0} rader) +

+ {correction.lines && correction.lines.length > 0 && ( + + )} + {correction.total_debit != null && ( +
+ + Summa + + {formatCurrency(correction.total_debit)} + + + {formatCurrency(correction.total_credit ?? 0)} + +
+ )} +
+
+ ) +} + +// Render a primitive (string/number/bool) or a short summary of an array/object. +// Used by GenericPreview to avoid the "[object Object]" stringification that +// occurs when an operation_type has no dedicated preview component. +function renderPrimitive(value: unknown): string { + if (value == null) return '' + if (Array.isArray(value)) return `${value.length} rader` + if (typeof value === 'object') return JSON.stringify(value) + return String(value) +} + +// A preview_data value that is a kontering (array of account/debit/credit +// rows). Several staged op types carry one under keys like `preview_lines` +// without a dedicated preview component; rendering it as the actual +// verifikat rows is what makes the detail panel say what the agent will do. +interface PreviewKonteringLine { + account?: string + account_number?: string + description?: string + debit?: number + credit?: number + debit_amount?: number + credit_amount?: number +} + +function isKonteringLines(value: unknown): value is PreviewKonteringLine[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (line) => + line != null && + typeof line === 'object' && + ('account' in line || 'account_number' in line) && + ('debit' in line || 'credit' in line || 'debit_amount' in line || 'credit_amount' in line), + ) + ) +} + +function PreviewKonteringTable({ lines }: { lines: PreviewKonteringLine[] }) { + const amount = (n: number | undefined) => + n && n > 0 ? n.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : '' + return ( + + + + + + + + + + + {lines.map((line, i) => ( + + + + + + + ))} + +
KontoBeskrivningDebetKredit
+ {line.account ?? line.account_number} + {line.description ?? ''} + {amount(line.debit ?? line.debit_amount)} + + {amount(line.credit ?? line.credit_amount)} +
+ ) +} + +function GenericPreview({ data }: { data: Record }) { + // Skip period_status here: it's surfaced in the dedicated banner, not the + // generic key-value dump (otherwise the approver sees the same fact twice). + const entries = Object.entries(data).filter(([k, v]) => v != null && v !== '' && k !== 'period_status') + const konteringEntries = entries.filter(([, v]) => isKonteringLines(v)) + const rest = entries.filter(([, v]) => !isKonteringLines(v)) + return ( +
+ {konteringEntries.map(([key, value]) => ( + + ))} + {rest.length > 0 && ( +
+ {rest.map(([key, value]) => ( + + {key.replace(/_/g, ' ')} + + {renderPrimitive(value)} + + + ))} +
+ )} +
+ ) +} + +export function OperationPreview({ op }: { op: OperationPreviewInput }) { + const body = (() => { + switch (op.operation_type) { + case 'categorize_transaction': + return + case 'create_customer': + return + case 'create_invoice': + return + case 'create_transaction': + return + case 'create_voucher': + return + case 'correct_entry': + return + case 'attach_document_to_transaction': + return + case 'match_transaction_invoice': + return + default: + return + } + })() + return body +} diff --git a/components/pending-operations/vocabulary.ts b/components/pending-operations/vocabulary.ts new file mode 100644 index 00000000..2337f592 --- /dev/null +++ b/components/pending-operations/vocabulary.ts @@ -0,0 +1,145 @@ +// Shared vocabulary for pending_operations rendering: labels, warnings and +// rejection categories used by every surface that shows a staged operation +// (/pending, the chat approval card, future flow-run views). Moved here from +// app/(dashboard)/pending/page.tsx so the vocabulary has exactly one owner. + +import type { PendingOperationRejectionCategory } from '@/types' + +// Short human label (i18n key in the "pending" namespace) for each staged +// operation_type. Keep in sync with OPERATION_RISK_TIERS in +// lib/pending-operations/risk-tiers.ts: every operation an agent can stage +// needs a label here, otherwise the Granskning list falls back to the raw +// snake_case tool name (e.g. "create_supplier_invoice_from_inbox"), which is +// long and pushes the meta row to wrap awkwardly on mobile. +export const OPERATION_LABEL_KEYS: Record = { + categorize_transaction: 'type_categorize_transaction', + create_customer: 'type_create_customer', + create_invoice: 'type_create_invoice', + create_transaction: 'type_create_transaction', + create_voucher: 'type_create_voucher', + correct_entry: 'type_correct_entry', + reverse_entry: 'type_reverse_entry', + mark_invoice_paid: 'type_mark_invoice_paid', + send_invoice: 'type_send_invoice', + mark_invoice_sent: 'type_mark_invoice_sent', + match_transaction_invoice: 'type_match_transaction_invoice', + // Master data + create_supplier: 'type_create_supplier', + create_article: 'type_create_article', + update_article: 'type_update_article', + create_account: 'type_create_account', + update_account: 'type_update_account', + create_dimension_value: 'type_create_dimension_value', + // Supplier invoices + create_supplier_invoice_from_inbox: 'type_create_supplier_invoice_from_inbox', + create_self_billed_supplier_invoice: 'type_create_self_billed_supplier_invoice', + approve_supplier_invoice: 'type_approve_supplier_invoice', + credit_supplier_invoice: 'type_credit_supplier_invoice', + // Invoices + credit_invoice: 'type_credit_invoice', + convert_invoice: 'type_convert_invoice', + // Documents & links + attach_document_to_transaction: 'type_attach_document_to_transaction', + link_document_to_voucher: 'type_link_document_to_voucher', + link_invoice_voucher: 'type_link_invoice_voucher', + link_supplier_invoice_voucher: 'type_link_supplier_invoice_voucher', + link_transaction_journal_entry: 'type_link_transaction_journal_entry', + uncategorize_transaction: 'type_uncategorize_transaction', + retag_line_dimensions: 'type_retag_line_dimensions', + set_voucher_note: 'type_set_voucher_note', + // Bulk booking / allocation + match_batch_allocate: 'type_match_batch_allocate', + bulk_book_transactions: 'type_bulk_book_transactions', + bulk_book_inbox_items: 'type_bulk_book_inbox_items', + // Periods, year-end, depreciation + close_period: 'type_close_period', + lock_period: 'type_lock_period', + unlock_period: 'type_unlock_period', + set_opening_balances: 'type_set_opening_balances', + run_year_end: 'type_run_year_end', + run_currency_revaluation: 'type_run_currency_revaluation', + post_annual_depreciation: 'type_post_annual_depreciation', + explain_voucher_gap: 'type_explain_voucher_gap', + // SIE + import_sie: 'type_import_sie', + undo_sie_import: 'type_undo_sie_import', + // Payroll & Skatteverket filings + create_salary_run: 'type_create_salary_run', + book_salary_run: 'type_book_salary_run', + generate_agi: 'type_generate_agi', + update_payslip_line: 'type_update_payslip_line', + register_absence: 'type_register_absence', + delete_absence: 'type_delete_absence', + create_employee: 'type_create_employee', + update_employee: 'type_update_employee', + set_employee_opening_balances: 'type_set_employee_opening_balances', + vacation_year_close: 'type_vacation_year_close', + submit_vat_declaration: 'type_submit_vat_declaration', + submit_agi: 'type_submit_agi', +} + +// Fallback for an operation_type with no entry above (e.g. a newly added op +// not yet given a label): turn "create_supplier_invoice_from_inbox" into +// "Create supplier invoice from inbox" so it never surfaces as raw snake_case. +export function humanizeOperationType(operationType: string): string { + const spaced = operationType.replace(/_/g, ' ') + return spaced.charAt(0).toUpperCase() + spaced.slice(1) +} + +export function operationLabel(operationType: string, t: (key: string) => string): string { + const labelKey = OPERATION_LABEL_KEYS[operationType] + return labelKey ? t(labelKey) : humanizeOperationType(operationType) +} + +// Full-sentence warning for the single-op confirmation dialog AND the inline +// list-view warning when risk is medium/high. The list-view truncates beyond +// one line; the dialog shows it in full. Order roughly low → high risk so +// reviewers scanning the source see the destructive paths grouped together. +export const singleActionWarnings: Record = { + // Low/medium risk: light verifikation work + create_transaction: 'Genom att klicka godkänn så skapar du en transaktion.', + create_customer: 'Genom att klicka godkänn så skapar du en kund.', + create_invoice: 'Genom att klicka godkänn så skapas ett fakturautkast (det skickas inte).', + categorize_transaction: 'Genom att klicka godkänn så kategoriseras transaktionen och en verifikation skapas.', + match_transaction_invoice: 'Genom att klicka godkänn så matchas transaktionen mot fakturan.', + attach_document_to_transaction: 'Genom att klicka godkänn så bifogas dokumentet till transaktionen.', + uncategorize_transaction: 'Genom att klicka godkänn så tas kategoriseringen bort.', + send_invoice: 'Genom att klicka godkänn så skickas fakturan till kunden.', + mark_invoice_paid: 'Genom att klicka godkänn så bokförs en betalning på fakturan.', + mark_invoice_sent: 'Genom att klicka godkänn så märks fakturan som skickad och en verifikation skapas.', + // High risk: period/year-end/voucher edits. These are the ones the reviewer + // really needs the warning for, so we keep them concrete: name the + // irreversibility or compliance consequence, not the generic risk-level. + lock_period: 'Genom att klicka godkänn så låses perioden: inga nya verifikationer kan bokföras tills den låses upp.', + unlock_period: 'Genom att klicka godkänn så låses perioden upp. Använd endast för rättelser; lås igen efter.', + close_period: 'Genom att klicka godkänn så stängs perioden permanent (BFL). Stängningen kan inte ångras.', + run_year_end: 'Genom att klicka godkänn så körs bokslut: resultatkonton nollställs, perioden låses, nästa period skapas.', + set_opening_balances: 'Genom att klicka godkänn så bokförs ingående balans i nästa period.', + run_currency_revaluation: 'Genom att klicka godkänn så bokförs valutaomvärdering (3960/7960).', + create_voucher: 'Genom att klicka godkänn så bokförs verifikationen med ett nytt löpnummer.', + correct_entry: 'Genom att klicka godkänn så stornas originalverifikationen och en rättelse bokförs (BFL 5 kap 5§).', + reverse_entry: 'Genom att klicka godkänn så stornas verifikationen: originalet behålls synligt (BFL 5 kap).', + credit_invoice: 'Genom att klicka godkänn så skapas en kreditfaktura och originalverifikationen stornas.', + credit_supplier_invoice: 'Genom att klicka godkänn så krediteras leverantörsfakturan och registreringsverifikationen stornas.', + approve_supplier_invoice: 'Genom att klicka godkänn så attesteras leverantörsfakturan och blir betalningsbar.', + convert_invoice: 'Genom att klicka godkänn så konverteras proformafakturan till en riktig faktura med F-nummer.', + import_sie: 'Genom att klicka godkänn så importeras SIE-filen: räkenskapsperiod, ingående balans och verifikationer skapas.', + explain_voucher_gap: 'Genom att klicka godkänn så dokumenteras förklaringen för verifikationsluckan (BFNAR 2013:2).', + post_annual_depreciation: 'Genom att klicka godkänn så bokförs planenlig avskrivning: en verifikation per tillgång.', +} + +export function singleActionWarning(operationType: string): string { + return singleActionWarnings[operationType] ?? '' +} + +// Structured rejection categories. One canonical list: /pending's reject +// dialog and the chat approval card's reject form render the same options +// and store the same values (surfaced back to the agent via +// gnubok_get_recent_rejections). +export const REJECTION_CATEGORY_LABELS: Record = { + wrong_category: 'Fel kategori / konto', + wrong_amount: 'Fel belopp', + duplicate: 'Dubblett', + wrong_period: 'Fel period', + other: 'Annat', +} diff --git a/lib/agent/chat/run-turn.ts b/lib/agent/chat/run-turn.ts index faa6d7ea..53f729ee 100644 --- a/lib/agent/chat/run-turn.ts +++ b/lib/agent/chat/run-turn.ts @@ -77,6 +77,10 @@ export type StreamEvent = kind: 'staged_operation' tool_use_id: string tool_name: string + // The tool-use input: the same values the staging tool stored as + // pending_operations.params. Carried so chat previews that need + // params (e.g. attach_document's DocumentViewButton) work live. + params: Record staged: StagedOperationResult } | { @@ -450,6 +454,7 @@ export async function runChatTurn(args: RunTurnArgs): Promise { kind: 'staged_operation', tool_use_id: tu.id, tool_name: tu.name, + params: tu.input as Record, staged: result, }) } diff --git a/lib/pending-operations/__tests__/tool-name.test.ts b/lib/pending-operations/__tests__/tool-name.test.ts new file mode 100644 index 00000000..c3b80c96 --- /dev/null +++ b/lib/pending-operations/__tests__/tool-name.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { + operationTypeFromToolName, + toolNameForOperationType, +} from '../tool-name' + +/** + * pending_operations.operation_type is the bare action name; the MCP layer + * and the live chat stream carry 'gnubok_'-prefixed tool names. Getting the + * mapping wrong is invisible in mocks but silently drops every hydrated + * approval card to the generic raw preview (the toolNameFor gotcha this + * module replaces), so the round-trip is pinned here. + */ + +describe('operationTypeFromToolName', () => { + it('strips the gnubok_ prefix from an MCP tool name', () => { + expect(operationTypeFromToolName('gnubok_categorize_transaction')).toBe( + 'categorize_transaction', + ) + expect(operationTypeFromToolName('gnubok_attach_document_to_transaction')).toBe( + 'attach_document_to_transaction', + ) + }) + + it('passes an already-bare operation_type through unchanged', () => { + expect(operationTypeFromToolName('categorize_transaction')).toBe( + 'categorize_transaction', + ) + }) + + it('passes a non-gnubok tool name through unchanged', () => { + expect(operationTypeFromToolName('some_vendor_tool')).toBe('some_vendor_tool') + expect(operationTypeFromToolName('remember_fact')).toBe('remember_fact') + }) +}) + +describe('toolNameForOperationType', () => { + it('prefixes a bare operation_type', () => { + expect(toolNameForOperationType('create_voucher')).toBe('gnubok_create_voucher') + }) + + it('never double-prefixes an already-prefixed name', () => { + expect(toolNameForOperationType('gnubok_create_voucher')).toBe( + 'gnubok_create_voucher', + ) + }) +}) + +describe('round-trip', () => { + // Every operation type with a specialized preview renderer must survive + // the trip in both directions: this is exactly the path a hydrated + // approval card's preview dispatch takes. + const opTypes = [ + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'create_transaction', + 'create_voucher', + 'correct_entry', + 'attach_document_to_transaction', + 'match_transaction_invoice', + ] + + it.each(opTypes)('%s -> tool name -> back', (opType) => { + expect(operationTypeFromToolName(toolNameForOperationType(opType))).toBe(opType) + }) +}) diff --git a/lib/pending-operations/tool-name.ts b/lib/pending-operations/tool-name.ts new file mode 100644 index 00000000..94321f02 --- /dev/null +++ b/lib/pending-operations/tool-name.ts @@ -0,0 +1,29 @@ +// pending_operations.operation_type stores the bare action name +// ('categorize_transaction'), while the MCP tool surface and the live chat +// stream carry the prefixed tool name ('gnubok_categorize_transaction'). +// These two functions are the single translation point between the two +// vocabularies: every surface that needs to cross over imports from here +// instead of hand-rolling a prefix. The 'gnubok_' wire prefix is deliberate +// and must stay (rebrand rule: wire-format identifiers keep the old name). + +const TOOL_NAME_PREFIX = 'gnubok_' + +/** + * MCP tool name -> bare operation_type. A name without the prefix is already + * a bare operation_type (or a non-gnubok tool) and passes through unchanged. + */ +export function operationTypeFromToolName(toolName: string): string { + return toolName.startsWith(TOOL_NAME_PREFIX) + ? toolName.slice(TOOL_NAME_PREFIX.length) + : toolName +} + +/** + * Bare operation_type -> MCP tool name. Defensive on already-prefixed input + * so a value that was a tool name all along is never double-prefixed. + */ +export function toolNameForOperationType(operationType: string): string { + return operationType.startsWith(TOOL_NAME_PREFIX) + ? operationType + : `${TOOL_NAME_PREFIX}${operationType}` +} diff --git a/types/index.ts b/types/index.ts index 51297b00..a392c87a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -4454,6 +4454,7 @@ export interface StoredStagedOperation { title?: string | null risk_level?: string | null preview_data?: unknown + params?: Record | null } // ============================================================