'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' // 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 } 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 } throw new Error(errorText(body.error) || `HTTP ${res.status}`) } // 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 ? err.message : '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(() => ({}))) as { error?: string } throw new Error(body.error || 'Kunde inte aktivera kontona.') } setAccountsToActivate(null) await handleCommit() } catch (err) { setState('error') setErrorMessage(err instanceof Error ? err.message : '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 text = await res.text() throw new Error(text || `HTTP ${res.status}`) } 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 ? err.message : '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', } } // 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

{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?