'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 { AttnLine } from '@/components/ui/attn-line' import { VTH_CLASS, VTD_CLASS } from '@/components/ui/dry-table' import type { PendingOperation } from '@/types' import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview' import { MatchTransactionInvoicePreview } from '@/components/bookkeeping/MatchTransactionInvoicePreview' // The subset of a PendingOperation the preview actually reads. operation_type // is widened to string: the chat surface derives it from an MCP tool name and // unknown values legitimately fall through to GenericPreview. params carries // tool inputs some renderers need (e.g. attach_document_to_transaction's // document_id); surfaces that only have preview_data may omit it. export interface OperationPreviewInput { operation_type: string preview_data: PendingOperation['preview_data'] params?: PendingOperation['params'] } /** * Account number -> account name, for the proposal previews. The value is * provided by whichever page owns the fetch lifecycle (/pending provides a * per-mount, per-company map; a consumer that provides nothing gets the * default {} and previews show the bare number, never a wrong name). */ export const AccountNamesContext = createContext>({}) /** Render '-' instead of "NaN kr" when a preview payload omits an amount. */ function money(v: unknown, currency: string): string { return typeof v === 'number' && Number.isFinite(v) ? formatCurrency(v, currency) : '-' } function CategorizePreview({ data }: { data: Record }) { const accountNames = useContext(AccountNamesContext) // The exact journal lines the approval will post (net cost line, VAT line, // gross bank line, SEK): staged by the server since the preview-lines fix. const lines = (data.lines as Array<{ account_number?: string; debit_amount?: number; credit_amount?: number; description?: string }>) || [] const vatLines = (data.vat_lines as Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>) || [] if (lines.length > 0) { // Journal lines are always SEK (BFL 5 kap 2 §). When the bank row itself // is in another currency, say so next to the lines: a 2 500 USD receipt // booked as 24 292,50 kr read as a wrong SEK figure to an approver who // only saw one of the two numbers. const txCurrency = (data.currency as string) || 'SEK' const txAmount = typeof data.amount === 'number' && Number.isFinite(data.amount) ? data.amount : null return (
{/* Entry date first: with two open fiscal years the approver could not tell which year a categorization belonged to. */} {typeof data.date === 'string' && data.date && (
Datum {data.date}
)}

Verifikat

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

Verifikat

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

Momsrader

{vatLines.map((line, i) => (
{line.account_number}{' '} {accountNames[line.account_number] || line.description} {accountNames[line.account_number] && line.description !== accountNames[line.account_number] ? ( · {line.description} ) : null} {line.debit_amount > 0 ? `D ${formatCurrency(line.debit_amount)}` : `K ${formatCurrency(line.credit_amount)}`}
))}
)}
) } function CustomerPreview({ data }: { data: Record }) { return (
Namn {String(data.name ?? '')} Typ {String(data.customer_type ?? '')} {data.customer_number ? ( <> Kundnr {String(data.customer_number)} ) : null} {data.email ? ( <> E-post {String(data.email)} ) : null} {data.org_number ? ( <> Org.nr {String(data.org_number)} ) : null} {data.personal_number_masked ? ( <> Personnr {String(data.personal_number_masked)} ) : null}
) } // One staged (or replaced) invoice line as the staging tools put it in // preview_data: create_invoice and update_invoice both carry the effective // vat_rate and any posting-account override, so a rebooking is visible to the // approver, not only the amount (issue #1642). interface PreviewInvoiceLine { description: string quantity: number unit: string unit_price?: number line_total: number vat_rate?: number revenue_account?: string | null article_id?: string | null line_type?: string // ROT/RUT and periodisering markers: a full replace that drops one of // these must be visible to the approver, not only the amounts. deduction_type?: string | null accrual_period_start?: string | null accrual_period_end?: string | null } function isPreviewInvoiceLines(value: unknown): value is PreviewInvoiceLine[] { return ( Array.isArray(value) && value.every((row) => row != null && typeof row === 'object' && typeof (row as PreviewInvoiceLine).description === 'string') ) } function InvoiceLineRows({ items, currency }: { items: PreviewInvoiceLine[]; currency: string }) { return (
{items.map((item, i) => (
{item.description} {item.line_type === 'text' ? null : ` (${item.quantity} ${item.unit})`} {typeof item.vat_rate === 'number' && item.line_type !== 'text' && ( · {item.vat_rate} % moms )} {item.revenue_account && ( · {item.revenue_account} )} {item.deduction_type && ( · {item.deduction_type === 'rot' ? 'ROT-avdrag' : 'RUT-avdrag'} )} {item.accrual_period_start && item.accrual_period_end && ( · periodiseras {item.accrual_period_start} till {item.accrual_period_end} )} {item.line_type === 'text' ? '' : money(item.line_total, currency)}
))}
) } function InvoicePreview({ data }: { data: Record }) { const items = isPreviewInvoiceLines(data.items) ? data.items : [] return (
Kund {String(data.customer_name ?? '')} Datum {String(data.invoice_date ?? '')} Förfallodatum {String(data.due_date ?? '')}
{items.length > 0 && (
)}
Netto {money(data.subtotal, (data.currency as string) || 'SEK')} Moms {money(data.vat_amount, (data.currency as string) || 'SEK')} Totalt {money(data.total, (data.currency as string) || 'SEK')}
) } const UPDATE_INVOICE_FIELD_LABELS: Record = { notes: 'Anteckningar', invoice_date: 'Fakturadatum', due_date: 'Förfallodatum', delivery_date: 'Leveransdatum', your_reference: 'Er referens', our_reference: 'Vår referens', } function UpdateInvoicePreview({ data }: { data: Record }) { const currency = (data.currency as string) || 'SEK' const changes = (data.changes && typeof data.changes === 'object' ? (data.changes as Record) : {}) const headerEntries = Object.entries(changes).filter( ([key, value]) => key !== 'items' && key !== 'default_dimensions' && value !== undefined, ) const hasDimensionChange = 'default_dimensions' in changes const dimensionBag = changes.default_dimensions as Record | undefined const newItems = isPreviewInvoiceLines(data.items) ? data.items : null const currentItems = isPreviewInvoiceLines(data.current_items) ? data.current_items : null return (
Kund {String(data.customer_name ?? '')} Faktura {data.invoice_number ? String(data.invoice_number) : 'utkast'} {headerEntries.map(([key, value]) => ( {UPDATE_INVOICE_FIELD_LABELS[key] ?? key.replace(/_/g, ' ')} {/* null is an explicit clear (delivery_date: null), not a missing value */} {value === null ? 'rensas' : renderPrimitive(value)} ))} {hasDimensionChange && ( <> Dimensioner {dimensionBag && Object.keys(dimensionBag).length > 0 ? Object.entries(dimensionBag).map(([dim, code]) => `${dim}: ${code}`).join(', ') : 'rensas'} )}
{/* Full replace: show what goes away next to what comes in, so a quantity fix that also moves revenue off the article's account (3041 to 3001) or changes the VAT rate is visible before approval. */} {currentItems && (
{currentItems.length > 0 ? 'Nuvarande rader (ersätts)' : 'Nuvarande rader: inga'}
{currentItems.length > 0 && }
)} {newItems && (
Nya rader
)} {newItems && typeof data.total === 'number' && (
Netto {money(data.subtotal, currency)} Moms {money(data.vat_amount, currency)} Totalt {money(data.total, currency)}
)}
) } function CreateTransactionPreview({ data }: { data: Record }) { const currency = (data.currency as string) || 'SEK' return (
Datum {String(data.date ?? '')} Beskrivning {String(data.description ?? '')} Belopp {money(data.amount, currency)} {data.external_id ? ( <> Extern referens {String(data.external_id)} ) : null}
) } type VoucherLine = { account_number: string account_name?: string | null debit_amount: number credit_amount: number line_description?: string | null } function VoucherLinesTable({ lines, currency }: { lines: VoucherLine[]; currency?: string }) { const accountNames = useContext(AccountNamesContext) return (
{lines.map((line, i) => { // The account's own name first (staged account_name, else the chart // name); the line text only when it adds something. const name = line.account_name || accountNames[line.account_number] || '' const text = line.line_description || '' return (
{line.account_number} {name || text || '-'} {name && text && text !== name ? ( · {text} ) : null} {line.debit_amount > 0 ? formatCurrency(line.debit_amount, currency || 'SEK') : ''} {line.credit_amount > 0 ? formatCurrency(line.credit_amount, currency || 'SEK') : ''}
) })}
) } function VoucherPreview({ data }: { data: Record }) { const lines = (data.lines as VoucherLine[]) || [] const totalDebit = data.total_debit as number | undefined const totalCredit = data.total_credit as number | undefined // Advisory, mirrors the MCP staging warning: a verifikat for a received // handling must carry the handling itself (BFL 5 kap 6 §). Gated on the // staged compliance_warning, not on document_attached: the server decides // when the warning applies (IB entries are exempt there), so this stays a // mirror instead of a second, looser policy. Older ops without the field // render unchanged. The wording stays conditional ("om ... avser en // mottagen handling"): internal entries (accruals, FX) legitimately lack // a kvitto, and flagging them as deficient would be wrong. const missingUnderlag = typeof data.compliance_warning === 'string' return (
{missingUnderlag && ( Underlag saknas: om verifikatet avser en mottagen handling ska handlingen användas som verifikation (BFL 5 kap 6 §). )}
Datum {String(data.entry_date ?? '')} Beskrivning {String(data.description ?? '')} Serie {String(data.voucher_series ?? 'A')}
{lines.length > 0 && (
Konto Text Debet Kredit
)} {totalDebit != null && totalCredit != null && (
Summa {formatCurrency(totalDebit)} {formatCurrency(totalCredit)}
)}
) } function BulkBookPreview({ data }: { data: Record }) { // Samlingsverifikat over N bank rows. The staged kontering IS what the RPC // posts on approval, so it is the load-bearing part of this card; the // aggregates alone ("-720, 2 tx, expense") cannot tell a right booking from // a wrong one. Journal lines are SEK; the bank sum is shown in the rows' // own currency so a foreign batch is never misread as SEK. const lines = (data.lines as VoucherLine[]) || [] const txCount = typeof data.tx_count === 'number' ? data.tx_count : null const txSum = typeof data.tx_sum === 'number' && Number.isFinite(data.tx_sum) ? data.tx_sum : null const currency = (data.currency as string) || 'SEK' const linkExisting = data.mode === 'link_existing' const totalDebit = lines.reduce((s, l) => s + (l.debit_amount > 0 ? l.debit_amount : 0), 0) const totalCredit = lines.reduce((s, l) => s + (l.credit_amount > 0 ? l.credit_amount : 0), 0) return (
Datum {String(data.tx_date ?? '')} Transaktioner {txCount ?? '-'} {txSum !== null ? ` · ${formatCurrency(txSum, currency)}` : ''} Åtgärd {linkExisting ? 'Länka till befintligt verifikat' : 'Ny samlingsverifikation'} {data.entry_description ? ( <> Beskrivning {String(data.entry_description)} ) : null}
{lines.length > 0 && (
Konto Text Debet Kredit
Summa {formatCurrency(totalDebit)} {formatCurrency(totalCredit)}
)} {linkExisting && lines.length === 0 && (

Transaktionerna kopplas till ett redan bokfört verifikat; ingen ny kontering skapas.

)}
) } function CorrectEntryPreview({ data }: { data: Record }) { const original = (data.original as { voucher?: string entry_date?: string description?: string lines?: VoucherLine[] }) || {} const correction = (data.correction as { total_debit?: number total_credit?: number line_count?: number lines?: VoucherLine[] }) || {} return (

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

{original.description ?? ''}

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

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

{correction.lines && correction.lines.length > 0 && ( )} {correction.total_debit != null && (
Summa {formatCurrency(correction.total_debit)} {formatCurrency(correction.total_credit ?? 0)}
)}
) } // Render a primitive (string/number/bool) or a short summary of an array/object. // Used by GenericPreview to avoid the "[object Object]" stringification that // occurs when an operation_type has no dedicated preview component. function renderPrimitive(value: unknown): string { if (value == null) return '' if (Array.isArray(value)) return `${value.length} rader` if (typeof value === 'object') return JSON.stringify(value) return String(value) } // A preview_data value that is a kontering (array of account/debit/credit // rows). Several staged op types carry one under keys like `preview_lines` // without a dedicated preview component; rendering it as the actual // verifikat rows is what makes the detail panel say what the agent will do. interface PreviewKonteringLine { account?: string account_number?: string description?: string debit?: number credit?: number debit_amount?: number credit_amount?: number } function isKonteringLines(value: unknown): value is PreviewKonteringLine[] { return ( Array.isArray(value) && value.length > 0 && value.every( (line) => line != null && typeof line === 'object' && ('account' in line || 'account_number' in line) && ('debit' in line || 'credit' in line || 'debit_amount' in line || 'credit_amount' in line), ) ) } function PreviewKonteringTable({ lines }: { lines: PreviewKonteringLine[] }) { const accountNames = useContext(AccountNamesContext) const amount = (n: number | undefined) => n && n > 0 ? n.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : '' return ( {lines.map((line, i) => ( ))}
Konto Beskrivning Debet Kredit
{line.account ?? line.account_number} {line.description || accountNames[String(line.account ?? line.account_number ?? '')] || ''} {amount(line.debit ?? line.debit_amount)} {amount(line.credit ?? line.credit_amount)}
) } function GenericPreview({ data }: { data: Record }) { // Skip period_status here: it's surfaced in the dedicated banner, not the // generic key-value dump (otherwise the approver sees the same fact twice). const entries = Object.entries(data).filter(([k, v]) => v != null && v !== '' && k !== 'period_status') const konteringEntries = entries.filter(([, v]) => isKonteringLines(v)) const rest = entries.filter(([, v]) => !isKonteringLines(v)) return (
{konteringEntries.map(([key, value]) => ( ))} {rest.length > 0 && (
{rest.map(([key, value]) => ( {key.replace(/_/g, ' ')} {renderPrimitive(value)} ))}
)}
) } export function OperationPreview({ op }: { op: OperationPreviewInput }) { const body = (() => { switch (op.operation_type) { case 'categorize_transaction': return case 'create_customer': return case 'create_invoice': return case 'update_invoice': return case 'create_transaction': return case 'create_voucher': return case 'bulk_book_transactions': return case 'correct_entry': return case 'attach_document_to_transaction': return case 'match_transaction_invoice': return default: return } })() return body }