'use client' import { useState } from 'react' import Link from 'next/link' import { Check, X, Loader2, AlertTriangle, Lock, ShieldCheck, ArrowRight } from 'lucide-react' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' 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' // Inline approval card for an agent-staged pending_operation. // // Risk tiers (plan §9, §12): // low: single-click "Godkänn". Trust UI for auto-approve lives // post-V0 (data model supports it via agent_profiles.trust_per_tool). // medium: single-click "Godkänn". // high: requires the user to type "godkänn" verbatim. Never auto- // approvable, by design (legal compliance). // // Reject is always one-click. // // The card posts to the existing /api/pending-operations//{commit,reject} // endpoints: same surface the Accounted "Förslag" page uses, so there is // 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. interface PeriodStatus { period_id?: string | null status: 'open' | 'locked' | 'closed' lock_date?: string | null } interface Props { operationId: string riskLevel: 'low' | 'medium' | 'high' message: string toolName?: string preview?: unknown periodStatus?: PeriodStatus // Fired after a reject that carries a reason: the chat feeds this synthetic // correction back as a hidden user turn so the agent re-proposes inline. onRequestCorrection?: (correctionMessage: string) => void } type State = 'pending' | 'committing' | 'committed' | 'rejecting' | 'rejected' | 'error' // Mirrors the granskning (/pending) reject dialog so chat rejections capture // the same structured feedback. Stored on the op + surfaced to the agent via // gnubok_get_recent_rejections. const REJECTION_CATEGORY_LABELS: Record = { wrong_category: 'Fel kategori / konto', wrong_amount: 'Fel belopp', duplicate: 'Dubblett', wrong_period: 'Fel period', other: 'Annat', } // Subset of fields the commit response may return that the success state // uses to deep-link to the freshly-created artifact. Different // operation_types return different shapes: only the ones we actually // surface as links are declared. interface CommitResultData { journal_entry_id?: string | null invoice_id?: string | null customer_id?: string | null supplier_invoice_id?: string | null // bulk_book_inbox_items creates N verifikationer, not one artifact: the // executor returns per-item counts instead of a single id. Surfaced as a // "N bokförda" summary + a link to the ledger (or the sole verifikat). booked_count?: number skipped_count?: number booked?: Array<{ journal_entry_id?: string | null }> } export default function ApprovalCard({ operationId, riskLevel, message, toolName, preview, periodStatus, onRequestCorrection, }: Props) { // Gating the AI re-propose path only: approving/rejecting the staged // operation is manual ledger work and stays enabled without the AI add-on. // What's paid is feeding a rejection back so the agent generates a *new* // proposal (an LLM call): that's suppressed when the company lacks `ai`. const hasAi = useCapability(CAPABILITY.ai) const [state, setState] = useState('pending') const [errorMessage, setErrorMessage] = useState(null) const [confirmText, setConfirmText] = useState('') // Reject-with-reason form (mirrors the granskning dialog). Clicking "Avslå" // opens it; both fields are optional. When a reason is given, the rejection // is fed back so the agent re-proposes. const [showRejectForm, setShowRejectForm] = useState(false) const [rejectCategory, setRejectCategory] = useState('') const [rejectReason, setRejectReason] = useState('') // Surfaced in the "Godkänt" success state so the user can jump directly // to the newly-created artifact (verifikation / faktura / kund) instead // of hunting through /bookkeeping. const [commitResult, setCommitResult] = useState(null) // Set when commit fails because the booking posts to BAS accounts not yet // active in the chart. Drives the inline "activate and approve" affordance // (the op stays pending server-side, so retrying after activation works). const [accountsToActivate, setAccountsToActivate] = useState(null) const requiresTextConfirm = riskLevel === 'high' const canCommit = !requiresTextConfirm || confirmText.trim().toLowerCase() === 'godkänn' async function handleCommit() { setState('committing') setErrorMessage(null) setAccountsToActivate(null) try { const res = await fetch(`/api/pending-operations/${operationId}/commit`, { method: 'POST', }) const body = (await res.json().catch(() => ({}))) as { data?: CommitResultData error?: string | { code?: string; message?: string; account_numbers?: string[] } } if (!res.ok) { // Recoverable: the booking posts to BAS accounts not active in the // chart. Offer to activate them and retry: the op stays pending. const structured = typeof body.error === 'object' && body.error !== null ? body.error : null if (structured?.code === 'ACCOUNTS_NOT_IN_CHART' && structured.account_numbers?.length) { setAccountsToActivate(structured.account_numbers) setState('pending') return } // Map the parsed body plus the status, never `new Error(body.error)`: // the route answers thrown errors with the canonical envelope // `{ error: { code, message } }`, and the Error constructor would // stringify that object to "[object Object]", discarding the route's // own Swedish reason. setState('error') setErrorMessage(getUserErrorMessage(body, { statusCode: res.status })) return } // Best-effort deep-link to the created artifact in the success state. if (body?.data) setCommitResult(body.data) setState('committed') } catch (err) { setState('error') setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte godkänna.') } } // Activate the missing BAS accounts (one POST) then retry the commit. The // pending_operation was left 'pending' server-side precisely so this retry // commits the same booking without re-staging it. async function handleActivateAndCommit() { if (!accountsToActivate || accountsToActivate.length === 0) return setState('committing') setErrorMessage(null) try { const res = await fetch('/api/bookkeeping/accounts/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account_numbers: accountsToActivate }), }) if (!res.ok) { const body = await res.json().catch(() => null) setState('error') setErrorMessage(getUserErrorMessage(body, { statusCode: res.status })) return } setAccountsToActivate(null) await handleCommit() } catch (err) { setState('error') setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte aktivera kontona.') } } async function handleReject() { setState('rejecting') setErrorMessage(null) const categoryLabel = rejectCategory ? REJECTION_CATEGORY_LABELS[rejectCategory] : null const reason = rejectReason.trim() // Both fields optional: a bare "Avvisa" still rejects (parity with the // granskning dialog and older bodyless clients). const body = rejectCategory || reason ? { ...(rejectCategory ? { rejection_category: rejectCategory } : {}), ...(reason ? { rejection_reason: reason } : {}), } : undefined try { const res = await fetch(`/api/pending-operations/${operationId}/reject`, { method: 'POST', ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}), }) if (!res.ok) { const body = await res.json().catch(() => null) setState('error') setErrorMessage(getUserErrorMessage(body, { statusCode: res.status })) return } setShowRejectForm(false) setState('rejected') // Feed the correction back so the agent re-proposes: only when the user // actually said what was wrong. A bare reject just stops here. const parts = [categoryLabel, reason].filter(Boolean) as string[] if (hasAi && parts.length > 0) { onRequestCorrection?.( `Jag avvisade förslaget. Det som var fel: ${parts.join(', ')}. Föreslå en korrigerad bokning.`, ) } } catch (err) { setState('error') setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte avslå.') } } if (state === 'committed') { // Build a deep-link to the newly-created artifact when the commit // response told us what it was. Falls back to nothing if no relevant // id was returned (e.g. period close / unlock / mark-as-sent). let deepLink: { href: string; label: string } | null = null if (commitResult?.journal_entry_id) { deepLink = { href: `/bookkeeping/${commitResult.journal_entry_id}`, label: 'Öppna verifikation', } } else if (commitResult?.invoice_id) { deepLink = { href: `/invoices/${commitResult.invoice_id}`, label: 'Öppna faktura', } } else if (commitResult?.supplier_invoice_id) { deepLink = { href: `/supplier-invoices/${commitResult.supplier_invoice_id}`, label: 'Öppna leverantörsfaktura', } } else if (commitResult?.customer_id) { deepLink = { href: `/customers/${commitResult.customer_id}`, label: 'Öppna kund', } } // Bulk operations (bulk_book_inbox_items) book N underlag at once and return // counts instead of a single id. Show the outcome ("N bokförda · M // överhoppade") (a bulk commit silently skips non-bookable items, so // without this the user can't tell whether anything was booked) and link to // the ledger list, or straight to the sole verifikat when exactly one landed. const bulkSummary = typeof commitResult?.booked_count === 'number' ? { booked: commitResult.booked_count, skipped: commitResult.skipped_count ?? 0 } : null if (bulkSummary && !deepLink) { const soleEntryId = bulkSummary.booked === 1 ? commitResult?.booked?.[0]?.journal_entry_id : null deepLink = soleEntryId ? { href: `/bookkeeping/${soleEntryId}`, label: 'Öppna verifikation' } : { href: '/bookkeeping', label: 'Öppna bokföringen' } } // The server's `message` field (e.g. "Operation staged for review … // Open the Accounted web app to approve or reject it.") was written for // MCP clients without an inline approval surface. Inside the in-app // chat it's redundant noise: the agent already narrated the why // above the card. We keep it accessible via aria-description for // screen readers but don't render it. return (

Godkänt

{bulkSummary && (

{bulkSummary.booked} {bulkSummary.booked === 1 ? 'underlag bokfört' : 'underlag bokförda'} {bulkSummary.skipped > 0 ? ` · ${bulkSummary.skipped} överhoppade` : ''}

)} {deepLink && ( {deepLink.label} )}
) } if (state === 'rejected') { return (

Avslaget {rejectCategory && ( · {REJECTION_CATEGORY_LABELS[rejectCategory]} )}

) } const isBusy = state === 'committing' || state === 'rejecting' return (

Förslag · risk {translateRisk(riskLevel)}

{periodStatus && }
{requiresTextConfirm && (

Hög risk: skriv godkänn för att bekräfta.

setConfirmText(e.target.value)} disabled={isBusy} className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" autoComplete="off" aria-label="Bekräfta med ordet godkänn" />
)} {errorMessage &&

{errorMessage}

} {showRejectForm ? (

Vad är fel?