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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-13 16:17:15 +02:00
committed by GitHub
parent 22eaab82e5
commit ce6efdb3dc
12 changed files with 794 additions and 929 deletions
+1
View File
@@ -894,6 +894,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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).
+1 -1
View File
@@ -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)
+9 -569
View File
@@ -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<string, string> = {
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<string, string> = {
// 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<PendingOperationRejectionCategory, string> = {
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<Record<string, string>>({})
function useAccountNamesSource(): Record<string, string> {
const [names, setNames] = useState<Record<string, string>>({})
useEffect(() => {
@@ -352,433 +219,6 @@ function useAccountNamesSource(): Record<string, string> {
}
function CategorizePreview({ data }: { data: Record<string, unknown> }) {
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 (
<div className="space-y-1 text-sm">
<p className="text-xs text-muted-foreground mb-1">Verifikat</p>
{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 (
<div key={i} className="flex justify-between gap-4 font-mono text-xs">
<span className="truncate">
{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. */}
<span className="text-foreground">
{(line.account_number && accountNames[line.account_number]) || line.description || ''}
</span>
{line.description &&
line.account_number &&
accountNames[line.account_number] &&
line.description !== accountNames[line.account_number] ? (
<span className="text-muted-foreground"> · {line.description}</span>
) : null}
</span>
<span className="tabular-nums shrink-0">
{debitAmt > 0 ? `D ${formatCurrency(debitAmt)}` : `K ${formatCurrency(creditAmt)}`}
</span>
</div>
)
})}
</div>
)
}
// 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 (
<div className="space-y-1 text-sm">
<p className="text-xs text-muted-foreground mb-1">Verifikat</p>
<PreviewKonteringTable lines={data.preview_lines} />
</div>
)
}
// 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 (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Debetkonto</span>
<span className="font-mono">{String(data.debit_account ?? '')}</span>
<span className="text-muted-foreground">Kreditkonto</span>
<span className="font-mono">{String(data.credit_account ?? '')}</span>
<span className="text-muted-foreground">Belopp</span>
<span className="font-mono tabular-nums">
{/* 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')}
</span>
</div>
{vatLines.length > 0 && (
<div className="border-t pt-2">
<p className="text-xs text-muted-foreground mb-1">Momsrader</p>
{vatLines.map((line, i) => (
<div key={i} className="flex justify-between font-mono text-xs">
<span>
{line.account_number}{' '}
{accountNames[line.account_number] || line.description}
{accountNames[line.account_number] &&
line.description !== accountNames[line.account_number] ? (
<span className="text-muted-foreground"> · {line.description}</span>
) : null}
</span>
<span className="tabular-nums">
{line.debit_amount > 0 ? `D ${formatCurrency(line.debit_amount)}` : `K ${formatCurrency(line.credit_amount)}`}
</span>
</div>
))}
</div>
)}
</div>
)
}
function CustomerPreview({ data }: { data: Record<string, unknown> }) {
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<span className="text-muted-foreground">Namn</span>
<span>{String(data.name ?? '')}</span>
<span className="text-muted-foreground">Typ</span>
<span>{String(data.customer_type ?? '')}</span>
{data.email ? (
<>
<span className="text-muted-foreground">E-post</span>
<span>{String(data.email)}</span>
</>
) : null}
{data.org_number ? (
<>
<span className="text-muted-foreground">Org.nr</span>
<span className="font-mono">{String(data.org_number)}</span>
</>
) : null}
</div>
)
}
function InvoicePreview({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate: number }>) || []
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Kund</span>
<span>{String(data.customer_name ?? '')}</span>
<span className="text-muted-foreground">Datum</span>
<span>{String(data.invoice_date ?? '')}</span>
<span className="text-muted-foreground">Förfallodatum</span>
<span>{String(data.due_date ?? '')}</span>
</div>
{items.length > 0 && (
<div className="border-t pt-2 space-y-1">
{items.map((item, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="truncate mr-4">{item.description} ({item.quantity} {item.unit})</span>
<span className="font-mono tabular-nums whitespace-nowrap">
{formatCurrency(item.line_total, (data.currency as string) || 'SEK')}
</span>
</div>
))}
</div>
)}
<div className="border-t pt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Netto</span>
<span className="tabular-nums text-right">{formatCurrency(data.subtotal as number, (data.currency as string) || 'SEK')}</span>
<span className="text-muted-foreground">Moms</span>
<span className="tabular-nums text-right">{formatCurrency(data.vat_amount as number, (data.currency as string) || 'SEK')}</span>
<span className="font-medium">Totalt</span>
<span className="tabular-nums font-medium text-right">{formatCurrency(data.total as number, (data.currency as string) || 'SEK')}</span>
</div>
</div>
)
}
function CreateTransactionPreview({ data }: { data: Record<string, unknown> }) {
const amount = data.amount as number
const currency = (data.currency as string) || 'SEK'
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<span className="text-muted-foreground">Datum</span>
<span className="font-mono">{String(data.date ?? '')}</span>
<span className="text-muted-foreground">Beskrivning</span>
<span className="truncate">{String(data.description ?? '')}</span>
<span className="text-muted-foreground">Belopp</span>
<span className="font-mono tabular-nums">
{formatCurrency(amount, currency)}
</span>
{data.external_id ? (
<>
<span className="text-muted-foreground">Extern referens</span>
<span className="font-mono text-xs truncate">{String(data.external_id)}</span>
</>
) : null}
</div>
)
}
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 (
<div className="border-t pt-2 space-y-1">
{lines.map((line, i) => (
<div key={i} className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-xs items-baseline">
<span className="font-mono text-muted-foreground">{line.account_number}</span>
<span className="truncate">
{line.account_name || line.line_description || '-'}
</span>
<span className="font-mono tabular-nums text-right w-24">
{line.debit_amount > 0 ? formatCurrency(line.debit_amount, currency || 'SEK') : ''}
</span>
<span className="font-mono tabular-nums text-right w-24">
{line.credit_amount > 0 ? formatCurrency(line.credit_amount, currency || 'SEK') : ''}
</span>
</div>
))}
</div>
)
}
function VoucherPreview({ data }: { data: Record<string, unknown> }) {
const lines = (data.lines as VoucherLine[]) || []
const totalDebit = data.total_debit as number | undefined
const totalCredit = data.total_credit as number | undefined
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Datum</span>
<span className="font-mono">{String(data.entry_date ?? '')}</span>
<span className="text-muted-foreground">Beskrivning</span>
<span className="truncate">{String(data.description ?? '')}</span>
<span className="text-muted-foreground">Serie</span>
<span className="font-mono">{String(data.voucher_series ?? 'A')}</span>
</div>
{lines.length > 0 && (
<div>
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-[11px] uppercase tracking-wider text-muted-foreground pb-1">
<span>Konto</span>
<span>Text</span>
<span className="text-right w-24">Debet</span>
<span className="text-right w-24">Kredit</span>
</div>
<VoucherLinesTable lines={lines} />
</div>
)}
{totalDebit != null && totalCredit != null && (
<div className="border-t pt-2 grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-xs">
<span></span>
<span className="text-muted-foreground">Summa</span>
<span className="font-mono tabular-nums text-right w-24 font-medium">
{formatCurrency(totalDebit)}
</span>
<span className="font-mono tabular-nums text-right w-24 font-medium">
{formatCurrency(totalCredit)}
</span>
</div>
)}
</div>
)
}
function CorrectEntryPreview({ data }: { data: Record<string, unknown> }) {
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 (
<div className="space-y-4 text-sm">
<div>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1">
Originalverifikation V{original.voucher ?? ''}, {original.entry_date ?? ''}
</p>
<p className="text-xs text-muted-foreground italic mb-2">{original.description ?? ''}</p>
{original.lines && original.lines.length > 0 && (
<VoucherLinesTable lines={original.lines} />
)}
</div>
<div>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1">
Korrigerad verifikation ({correction.line_count ?? correction.lines?.length ?? 0} rader)
</p>
{correction.lines && correction.lines.length > 0 && (
<VoucherLinesTable lines={correction.lines} />
)}
{correction.total_debit != null && (
<div className="border-t pt-1 grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-xs mt-1">
<span></span>
<span className="text-muted-foreground">Summa</span>
<span className="font-mono tabular-nums text-right w-24 font-medium">
{formatCurrency(correction.total_debit)}
</span>
<span className="font-mono tabular-nums text-right w-24 font-medium">
{formatCurrency(correction.total_credit ?? 0)}
</span>
</div>
)}
</div>
</div>
)
}
// 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 (
<table className="w-full border-collapse text-[12.5px]" aria-label="Föreslagen kontering">
<thead>
<tr>
<th className={cn(VTH_CLASS, 'w-[70px]')}>Konto</th>
<th className={VTH_CLASS}>Beskrivning</th>
<th className={cn(VTH_CLASS, 'text-right')}>Debet</th>
<th className={cn(VTH_CLASS, 'text-right')}>Kredit</th>
</tr>
</thead>
<tbody>
{lines.map((line, i) => (
<tr key={i}>
<td className={cn(VTD_CLASS, 'whitespace-nowrap font-mono tabular-nums')}>
{line.account ?? line.account_number}
</td>
<td className={cn(VTD_CLASS, 'text-muted-foreground')}>{line.description ?? ''}</td>
<td className={cn(VTD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{amount(line.debit ?? line.debit_amount)}
</td>
<td className={cn(VTD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{amount(line.credit ?? line.credit_amount)}
</td>
</tr>
))}
</tbody>
</table>
)
}
function GenericPreview({ data }: { data: Record<string, unknown> }) {
// 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 (
<div className="space-y-3">
{konteringEntries.map(([key, value]) => (
<PreviewKonteringTable key={key} lines={value as PreviewKonteringLine[]} />
))}
{rest.length > 0 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{rest.map(([key, value]) => (
<Fragment key={key}>
<span className="text-muted-foreground">{key.replace(/_/g, ' ')}</span>
<span className={typeof value === 'number' ? 'font-mono tabular-nums' : ''}>
{renderPrimitive(value)}
</span>
</Fragment>
))}
</div>
)}
</div>
)
}
function OperationPreview({ op }: { op: PendingOperation }) {
const body = (() => {
switch (op.operation_type) {
case 'categorize_transaction':
return <CategorizePreview data={op.preview_data} />
case 'create_customer':
return <CustomerPreview data={op.preview_data} />
case 'create_invoice':
return <InvoicePreview data={op.preview_data} />
case 'create_transaction':
return <CreateTransactionPreview data={op.preview_data} />
case 'create_voucher':
return <VoucherPreview data={op.preview_data} />
case 'correct_entry':
return <CorrectEntryPreview data={op.preview_data} />
case 'attach_document_to_transaction':
return <AttachDocumentPreview data={op.preview_data} params={op.params} />
case 'match_transaction_invoice':
return <MatchTransactionInvoicePreview data={op.preview_data} />
default:
return <GenericPreview data={op.preview_data} />
}
})()
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
+18 -15
View File
@@ -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<string, unknown>
// 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<string, unknown> | 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<string, unknown> | undefined,
preview: op.preview_data,
}))
+36 -331
View File
@@ -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<string, unknown>
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<PendingOperationRejectionCategory, string> = {
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 && <PeriodBadge status={periodStatus} />}
</div>
<PreviewBlock toolName={toolName} preview={preview} />
{preview != null && typeof preview === 'object' && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2">
<OperationPreview
op={{
operation_type: previewOperationType,
preview_data: preview as Record<string, unknown>,
params,
}}
/>
</div>
)}
{requiresTextConfirm && (
<div className="space-y-1">
@@ -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<string, unknown>
if (toolName === 'gnubok_categorize_transaction') {
return <CategorizeTransactionPreview preview={p} />
}
if (toolName === 'gnubok_create_invoice') {
return <CreateInvoicePreview preview={p} />
}
if (toolName === 'gnubok_create_voucher' || toolName === 'gnubok_correct_entry') {
return <VoucherPreview preview={p} />
}
return <GenericPreview preview={p} />
}
// 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<string, unknown>
}) {
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 (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1.5">
<div className="flex items-baseline gap-3">
<span className="w-20 shrink-0 text-muted-foreground text-[10px] uppercase tracking-wider">
Kategori
</span>
<span className="flex-1 min-w-0 leading-5 text-foreground">
{prettyCategory(category)}
</span>
</div>
{lines.length > 0 ? (
<div className="pt-1 mt-1 border-t border-border space-y-0.5">
{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 (
<Row
key={i}
label={i === 0 ? 'Verifikat' : ''}
value={
<span className="tabular-nums">
<span className="text-muted-foreground">{side} </span>
<strong className="font-medium">{l.account_number ?? '?'}</strong>
<span className="ml-2">
{formatCurrency(side === 'D' ? debitAmt : creditAmt)}
</span>
</span>
}
/>
)
})}
</div>
) : (
<>
{debit && credit && amount != null && (
<Row
label="Bokning"
value={
<span className="tabular-nums">
<span className="text-muted-foreground">D </span>
<strong className="font-medium">{debit}</strong>
<span className="text-muted-foreground"> / K </span>
<strong className="font-medium">{credit}</strong>
<span className="ml-2">{formatCurrency(amount, currency)}</span>
</span>
}
/>
)}
{vatLines.length > 0 && (
<div className="pt-1 mt-1 border-t border-border">
{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 (
<Row
key={i}
label={i === 0 ? 'Moms' : ''}
value={
<span className="tabular-nums">
{side && <span className="text-muted-foreground">{side} </span>}
<span className="text-muted-foreground">{v.account_number ?? ''} </span>
{formatCurrency(amount, currency)}
</span>
}
/>
)
})}
</div>
)}
</>
)}
</div>
)
}
// 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<string, unknown> }) {
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 (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1.5">
{customer && (
<Row label="Kund" value={<span className="text-foreground">{customer}</span>} />
)}
{items.length > 0 && (
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{items.slice(0, 5).map((it, i) => (
<Row
key={i}
label={i === 0 ? 'Rader' : ''}
value={
<span className="tabular-nums truncate">
<span className="text-muted-foreground">
{it.description ?? '(rad)'}
</span>
{it.line_total != null && (
<span className="ml-2">{formatCurrency(it.line_total, currency)}</span>
)}
</span>
}
/>
))}
{items.length > 5 && (
<p className="pl-[88px] text-muted-foreground/70">
+ {items.length - 5} ytterligare rader
</p>
)}
</div>
)}
<div className="pt-1 mt-1 border-t border-border space-y-0.5">
{subtotal != null && (
<Row
label="Netto"
value={
<span className="tabular-nums">{formatCurrency(subtotal, currency)}</span>
}
/>
)}
{vatAmount != null && (
<Row
label="Moms"
value={
<span className="tabular-nums">{formatCurrency(vatAmount, currency)}</span>
}
/>
)}
{total != null && (
<Row
label="Totalt"
value={
<span className="tabular-nums font-medium text-foreground">
{formatCurrency(total, currency)}
</span>
}
/>
)}
</div>
</div>
)
}
function VoucherPreview({ preview }: { preview: Record<string, unknown> }) {
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 <GenericPreview preview={preview} />
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1.5">
{date && <Row label="Datum" value={<span className="tabular-nums">{date}</span>} />}
{description && (
<Row label="Notering" value={<span className="text-foreground">{description}</span>} />
)}
<div className="pt-1 mt-1 border-t border-border space-y-0.5">
{lines.map((l, i) => (
<Row
key={i}
label={i === 0 ? 'Rader' : ''}
value={
<span className="tabular-nums">
<strong className="font-medium">{l.account ?? '?'}</strong>
<span className="text-muted-foreground"> · </span>
{l.debit != null && l.debit !== 0 && <span>D {formatCurrency(l.debit)}</span>}
{l.credit != null && l.credit !== 0 && <span>K {formatCurrency(l.credit)}</span>}
{l.description && (
<span className="text-muted-foreground/70 ml-2 truncate">{l.description}</span>
)}
</span>
}
/>
))}
</div>
</div>
)
}
// 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<string, unknown> }) {
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 (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1">
{rows.map((r) => (
<Row key={r.key} label={r.key} value={<span className="tabular-nums">{r.value}</span>} />
))}
</div>
)
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex gap-3 items-baseline">
<span className="w-20 shrink-0 text-muted-foreground text-[10px] uppercase tracking-wider">
{label}
</span>
<span className="flex-1 min-w-0 leading-5">{value}</span>
</div>
)
}
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 (
@@ -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' }]
@@ -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<Record<string, string>>({})
/** 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<string, unknown> }) {
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 (
<div className="space-y-1 text-sm">
<p className="text-xs text-muted-foreground mb-1">Verifikat</p>
{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 (
<div key={i} className="flex justify-between gap-4 font-mono text-xs">
<span className="truncate">
{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. */}
<span className="text-foreground">
{(line.account_number && accountNames[line.account_number]) || line.description || ''}
</span>
{line.description &&
line.account_number &&
accountNames[line.account_number] &&
line.description !== accountNames[line.account_number] ? (
<span className="text-muted-foreground"> · {line.description}</span>
) : null}
</span>
<span className="tabular-nums shrink-0">
{debitAmt > 0 ? `D ${formatCurrency(debitAmt)}` : `K ${formatCurrency(creditAmt)}`}
</span>
</div>
)
})}
</div>
)
}
// 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 (
<div className="space-y-1 text-sm">
<p className="text-xs text-muted-foreground mb-1">Verifikat</p>
<PreviewKonteringTable lines={data.preview_lines} />
</div>
)
}
// 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 (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Debetkonto</span>
<span className="font-mono">{String(data.debit_account ?? '')}</span>
<span className="text-muted-foreground">Kreditkonto</span>
<span className="font-mono">{String(data.credit_account ?? '')}</span>
<span className="text-muted-foreground">Belopp</span>
<span className="font-mono tabular-nums">
{/* 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')}
</span>
</div>
{vatLines.length > 0 && (
<div className="border-t pt-2">
<p className="text-xs text-muted-foreground mb-1">Momsrader</p>
{vatLines.map((line, i) => (
<div key={i} className="flex justify-between font-mono text-xs">
<span>
{line.account_number}{' '}
{accountNames[line.account_number] || line.description}
{accountNames[line.account_number] &&
line.description !== accountNames[line.account_number] ? (
<span className="text-muted-foreground"> · {line.description}</span>
) : null}
</span>
<span className="tabular-nums">
{line.debit_amount > 0 ? `D ${formatCurrency(line.debit_amount)}` : `K ${formatCurrency(line.credit_amount)}`}
</span>
</div>
))}
</div>
)}
</div>
)
}
function CustomerPreview({ data }: { data: Record<string, unknown> }) {
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<span className="text-muted-foreground">Namn</span>
<span>{String(data.name ?? '')}</span>
<span className="text-muted-foreground">Typ</span>
<span>{String(data.customer_type ?? '')}</span>
{data.email ? (
<>
<span className="text-muted-foreground">E-post</span>
<span>{String(data.email)}</span>
</>
) : null}
{data.org_number ? (
<>
<span className="text-muted-foreground">Org.nr</span>
<span className="font-mono">{String(data.org_number)}</span>
</>
) : null}
</div>
)
}
function InvoicePreview({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate: number }>) || []
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Kund</span>
<span>{String(data.customer_name ?? '')}</span>
<span className="text-muted-foreground">Datum</span>
<span>{String(data.invoice_date ?? '')}</span>
<span className="text-muted-foreground">Förfallodatum</span>
<span>{String(data.due_date ?? '')}</span>
</div>
{items.length > 0 && (
<div className="border-t pt-2 space-y-1">
{items.map((item, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="truncate mr-4">{item.description} ({item.quantity} {item.unit})</span>
<span className="font-mono tabular-nums whitespace-nowrap">
{formatCurrency(item.line_total, (data.currency as string) || 'SEK')}
</span>
</div>
))}
</div>
)}
<div className="border-t pt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Netto</span>
<span className="tabular-nums text-right">{money(data.subtotal, (data.currency as string) || 'SEK')}</span>
<span className="text-muted-foreground">Moms</span>
<span className="tabular-nums text-right">{money(data.vat_amount, (data.currency as string) || 'SEK')}</span>
<span className="font-medium">Totalt</span>
<span className="tabular-nums font-medium text-right">{money(data.total, (data.currency as string) || 'SEK')}</span>
</div>
</div>
)
}
function CreateTransactionPreview({ data }: { data: Record<string, unknown> }) {
const currency = (data.currency as string) || 'SEK'
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<span className="text-muted-foreground">Datum</span>
<span className="font-mono">{String(data.date ?? '')}</span>
<span className="text-muted-foreground">Beskrivning</span>
<span className="truncate">{String(data.description ?? '')}</span>
<span className="text-muted-foreground">Belopp</span>
<span className="font-mono tabular-nums">
{money(data.amount, currency)}
</span>
{data.external_id ? (
<>
<span className="text-muted-foreground">Extern referens</span>
<span className="font-mono text-xs truncate">{String(data.external_id)}</span>
</>
) : null}
</div>
)
}
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 (
<div className="border-t pt-2 space-y-1">
{lines.map((line, i) => (
<div key={i} className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-xs items-baseline">
<span className="font-mono text-muted-foreground">{line.account_number}</span>
<span className="truncate">
{line.account_name || line.line_description || '-'}
</span>
<span className="font-mono tabular-nums text-right w-24">
{line.debit_amount > 0 ? formatCurrency(line.debit_amount, currency || 'SEK') : ''}
</span>
<span className="font-mono tabular-nums text-right w-24">
{line.credit_amount > 0 ? formatCurrency(line.credit_amount, currency || 'SEK') : ''}
</span>
</div>
))}
</div>
)
}
function VoucherPreview({ data }: { data: Record<string, unknown> }) {
const lines = (data.lines as VoucherLine[]) || []
const totalDebit = data.total_debit as number | undefined
const totalCredit = data.total_credit as number | undefined
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Datum</span>
<span className="font-mono">{String(data.entry_date ?? '')}</span>
<span className="text-muted-foreground">Beskrivning</span>
<span className="truncate">{String(data.description ?? '')}</span>
<span className="text-muted-foreground">Serie</span>
<span className="font-mono">{String(data.voucher_series ?? 'A')}</span>
</div>
{lines.length > 0 && (
<div>
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-[11px] uppercase tracking-wider text-muted-foreground pb-1">
<span>Konto</span>
<span>Text</span>
<span className="text-right w-24">Debet</span>
<span className="text-right w-24">Kredit</span>
</div>
<VoucherLinesTable lines={lines} />
</div>
)}
{totalDebit != null && totalCredit != null && (
<div className="border-t pt-2 grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-xs">
<span></span>
<span className="text-muted-foreground">Summa</span>
<span className="font-mono tabular-nums text-right w-24 font-medium">
{formatCurrency(totalDebit)}
</span>
<span className="font-mono tabular-nums text-right w-24 font-medium">
{formatCurrency(totalCredit)}
</span>
</div>
)}
</div>
)
}
function CorrectEntryPreview({ data }: { data: Record<string, unknown> }) {
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 (
<div className="space-y-4 text-sm">
<div>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1">
Originalverifikation V{original.voucher ?? ''}, {original.entry_date ?? ''}
</p>
<p className="text-xs text-muted-foreground italic mb-2">{original.description ?? ''}</p>
{original.lines && original.lines.length > 0 && (
<VoucherLinesTable lines={original.lines} />
)}
</div>
<div>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1">
Korrigerad verifikation ({correction.line_count ?? correction.lines?.length ?? 0} rader)
</p>
{correction.lines && correction.lines.length > 0 && (
<VoucherLinesTable lines={correction.lines} />
)}
{correction.total_debit != null && (
<div className="border-t pt-1 grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-xs mt-1">
<span></span>
<span className="text-muted-foreground">Summa</span>
<span className="font-mono tabular-nums text-right w-24 font-medium">
{formatCurrency(correction.total_debit)}
</span>
<span className="font-mono tabular-nums text-right w-24 font-medium">
{formatCurrency(correction.total_credit ?? 0)}
</span>
</div>
)}
</div>
</div>
)
}
// 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 (
<table className="w-full border-collapse text-[12.5px]" aria-label="Föreslagen kontering">
<thead>
<tr>
<th className={cn(VTH_CLASS, 'w-[70px]')}>Konto</th>
<th className={VTH_CLASS}>Beskrivning</th>
<th className={cn(VTH_CLASS, 'text-right')}>Debet</th>
<th className={cn(VTH_CLASS, 'text-right')}>Kredit</th>
</tr>
</thead>
<tbody>
{lines.map((line, i) => (
<tr key={i}>
<td className={cn(VTD_CLASS, 'whitespace-nowrap font-mono tabular-nums')}>
{line.account ?? line.account_number}
</td>
<td className={cn(VTD_CLASS, 'text-muted-foreground')}>{line.description ?? ''}</td>
<td className={cn(VTD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{amount(line.debit ?? line.debit_amount)}
</td>
<td className={cn(VTD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{amount(line.credit ?? line.credit_amount)}
</td>
</tr>
))}
</tbody>
</table>
)
}
function GenericPreview({ data }: { data: Record<string, unknown> }) {
// 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 (
<div className="space-y-3">
{konteringEntries.map(([key, value]) => (
<PreviewKonteringTable key={key} lines={value as PreviewKonteringLine[]} />
))}
{rest.length > 0 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{rest.map(([key, value]) => (
<Fragment key={key}>
<span className="text-muted-foreground">{key.replace(/_/g, ' ')}</span>
<span className={typeof value === 'number' ? 'font-mono tabular-nums' : ''}>
{renderPrimitive(value)}
</span>
</Fragment>
))}
</div>
)}
</div>
)
}
export function OperationPreview({ op }: { op: OperationPreviewInput }) {
const body = (() => {
switch (op.operation_type) {
case 'categorize_transaction':
return <CategorizePreview data={op.preview_data} />
case 'create_customer':
return <CustomerPreview data={op.preview_data} />
case 'create_invoice':
return <InvoicePreview data={op.preview_data} />
case 'create_transaction':
return <CreateTransactionPreview data={op.preview_data} />
case 'create_voucher':
return <VoucherPreview data={op.preview_data} />
case 'correct_entry':
return <CorrectEntryPreview data={op.preview_data} />
case 'attach_document_to_transaction':
return <AttachDocumentPreview data={op.preview_data} params={op.params ?? {}} />
case 'match_transaction_invoice':
return <MatchTransactionInvoicePreview data={op.preview_data} />
default:
return <GenericPreview data={op.preview_data} />
}
})()
return body
}
+145
View File
@@ -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<string, string> = {
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<string, string> = {
// 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<PendingOperationRejectionCategory, string> = {
wrong_category: 'Fel kategori / konto',
wrong_amount: 'Fel belopp',
duplicate: 'Dubblett',
wrong_period: 'Fel period',
other: 'Annat',
}
+5
View File
@@ -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<string, unknown>
staged: StagedOperationResult
}
| {
@@ -450,6 +454,7 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
kind: 'staged_operation',
tool_use_id: tu.id,
tool_name: tu.name,
params: tu.input as Record<string, unknown>,
staged: result,
})
}
@@ -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)
})
})
+29
View File
@@ -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}`
}
+1
View File
@@ -4454,6 +4454,7 @@ export interface StoredStagedOperation {
title?: string | null
risk_level?: string | null
preview_data?: unknown
params?: Record<string, unknown> | null
}
// ============================================================