Files
accounted/components/pending-operations/OperationPreview.tsx
T
ce6efdb3dc 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>
2026-08-13 16:17:15 +02:00

466 lines
19 KiB
TypeScript

'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
}