'use client' import { useState, useEffect, useCallback, useMemo, Fragment } from 'react' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' 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 { SlideOver, SlideOverContent, SlideOverHeader, SlideOverBody, SlideOverFooter, } from '@/components/ui/slide-over' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { useToast } from '@/components/ui/use-toast' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' import { createClient } from '@/lib/supabase/client' import { ClipboardCheck, Bot, Check, ChevronRight, Info, Loader2, Lock, MessageSquare, AlertTriangle, X, } from 'lucide-react' 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 = { 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) } // 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:". const bulkActionDescriptions: Record string> = { create_transaction: (n) => n === 1 ? 'En transaktion skapas.' : `${n} transaktioner skapas.`, create_customer: (n) => (n === 1 ? 'En ny kund skapas.' : `${n} nya kunder skapas.`), create_invoice: (n) => n === 1 ? 'Ett fakturautkast skapas (skickas inte).' : `${n} fakturautkast skapas (skickas inte).`, categorize_transaction: (n) => n === 1 ? 'En transaktion kategoriseras och bokförs.' : `${n} transaktioner kategoriseras och bokförs.`, match_transaction_invoice: (n) => n === 1 ? 'En transaktion matchas mot en faktura.' : `${n} transaktioner matchas mot fakturor.`, attach_document_to_transaction: (n) => n === 1 ? 'Ett dokument bifogas en transaktion.' : `${n} dokument bifogas transaktioner.`, uncategorize_transaction: (n) => n === 1 ? 'En kategorisering tas bort.' : `${n} kategoriseringar tas bort.`, } function bulkActionLabel(operationType: string, count: number, t: (key: string) => string): string { const fn = bulkActionDescriptions[operationType] if (fn) return fn(count) 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 = { // 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 { period_id: string | null status: 'open' | 'locked' | 'closed' lock_date: string | null } function getPeriodStatus(op: PendingOperation): PeriodStatusShape | null { const raw = (op.preview_data as Record)?.period_status if (!raw || typeof raw !== 'object') return null const obj = raw as Record const status = obj.status if (status !== 'open' && status !== 'locked' && status !== 'closed') return null return { period_id: typeof obj.period_id === 'string' ? obj.period_id : null, status, lock_date: typeof obj.lock_date === 'string' ? obj.lock_date : null, } } // Concept gact buttons (scene 11): tinted outline pills under the op text. // The tint is sanctioned here by the concept spec: approve reads sage, // reject terracotta, details neutral. const GACT_CLASS = 'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-[5px] text-xs transition-colors duration-150 disabled:pointer-events-none disabled:opacity-50' const GACT_OK_CLASS = 'border-success/40 text-success hover:bg-success/10' const GACT_NO_CLASS = 'border-destructive/40 text-destructive hover:bg-destructive/10' const GACT_NEUTRAL_CLASS = 'border-border text-muted-foreground hover:bg-secondary/40 hover:text-foreground' const REJECTION_CATEGORY_LABELS: Record = { 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 * not enough context: spell out where the proposal came from. */ function originLabel( op: PendingOperation, t: (key: string, values?: Record) => string, ): string | null { switch (op.actor_type) { case 'agent_chat': return t('origin_agent_chat') // The claude.ai MCP connector mints a gnubok_sk_ key, so MCP traffic // arrives as actor_type='api_key' with the key name as actor_label: // keep the label so users with several integrations can tell which one // staged the op. 'mcp_oauth' is declared but currently unreachable. case 'mcp_oauth': case 'api_key': return op.actor_label ? t('origin_mcp', { label: op.actor_label }) : t('origin_api') case 'cron': return t('origin_cron') default: return null } } /** * Rows the expiry cron auto-rejected (app/api/pending-operations/expire/cron). * Strict on reason === 'expired' so commit-time auto-rejects (404/409, where * reason is the error text) do NOT read as "expired". */ /** * failed_partial rows (issue #842): the executor posted an irreversible * voucher/credit note and then failed a later step. The dispatcher persisted * the ids of what WAS posted in result_data.posted_ids; render them so the * reviewer can locate the orphaned entity. */ function failedPartialPostedIds(op: PendingOperation): string | null { const rd = op.result_data as { posted_ids?: Record } | null const entries = Object.entries(rd?.posted_ids ?? {}) if (entries.length === 0) return null return entries.map(([key, value]) => `${key}: ${value}`).join(', ') } function isAutoExpired(op: PendingOperation): boolean { const rd = op.result_data as { auto_rejected?: boolean; reason?: string } | null return op.status === 'rejected' && rd?.auto_rejected === true && rd?.reason === 'expired' } function formatRelativeTime(dateStr: string): string { const now = new Date() const date = new Date(dateStr) const diffMs = now.getTime() - date.getTime() const diffMin = Math.floor(diffMs / 60000) if (diffMin < 1) return 'just nu' if (diffMin < 60) return `${diffMin} min sedan` const diffHours = Math.floor(diffMin / 60) if (diffHours < 24) return `${diffHours} tim sedan` const diffDays = Math.floor(diffHours / 24) return `${diffDays} dagar sedan` } function CategorizePreview({ data }: { data: Record }) { // 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 (

Verifikat

{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 ?? '?'}{line.description ? ` ${line.description}` : ''} {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} {line.description} {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.email ? ( <> E-post {String(data.email)} ) : null} {data.org_number ? ( <> Org.nr {String(data.org_number)} ) : null}
) } function InvoicePreview({ data }: { data: Record }) { const items = (data.items as Array<{ description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate: number }>) || [] return (
Kund {String(data.customer_name ?? '')} Datum {String(data.invoice_date ?? '')} Förfallodatum {String(data.due_date ?? '')}
{items.length > 0 && (
{items.map((item, i) => (
{item.description} ({item.quantity} {item.unit}) {formatCurrency(item.line_total, (data.currency as string) || 'SEK')}
))}
)}
Netto {formatCurrency(data.subtotal as number, (data.currency as string) || 'SEK')} Moms {formatCurrency(data.vat_amount as number, (data.currency as string) || 'SEK')} Totalt {formatCurrency(data.total as number, (data.currency as string) || 'SEK')}
) } function CreateTransactionPreview({ data }: { data: Record }) { const amount = data.amount as number const currency = (data.currency as string) || 'SEK' return (
Datum {String(data.date ?? '')} Beskrivning {String(data.description ?? '')} Belopp {formatCurrency(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 }) { return (
{lines.map((line, i) => (
{line.account_number} {line.account_name || line.line_description || '-'} {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 return (
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 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 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 ?? ''} {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)} ))}
)}
) } function OperationPreview({ op }: { op: PendingOperation }) { const body = (() => { switch (op.operation_type) { case 'categorize_transaction': return case 'create_customer': return case 'create_invoice': return case 'create_transaction': return case 'create_voucher': return case 'correct_entry': return case 'attach_document_to_transaction': return case 'match_transaction_invoice': return default: return } })() 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 * will reject it, so we tell the approver up front rather than letting them * click and see a generic "Misslyckades" toast. The fiscal_period_id link * goes to the periods management page where unlocking is possible. */ function PeriodLockBanner({ period }: { period: PeriodStatusShape }) { const lockedThrough = period.lock_date ? formatDate(period.lock_date) : null return (

{period.status === 'closed' ? 'Perioden är stängd permanent (BFL): kan inte ändras.' : `Perioden är låst${lockedThrough ? ` t.o.m. ${lockedThrough}` : ''}.`}

{period.status === 'closed' ? 'Använd en omprövning i en öppen period i stället.' : 'Lås upp perioden via Bokföring → Räkenskapsperioder, ändra entry-datum, eller avvisa.'}

) } type SourceFilter = 'all' | 'agent' | 'high_risk' const sourceFilterLabels = ( t: (key: string) => string, ): Record => ({ all: t('tab_all'), agent: t('tab_agent'), high_risk: t('tab_high_risk'), }) // Concept scene 11: two views. Historik merges committed + rejected, // distinguished per row by a status chip. type ViewTab = 'pending' | 'history' export default function PendingOperationsPage() { const t = useTranslations('pending') const [operations, setOperations] = useState([]) const [isLoading, setIsLoading] = useState(true) const [activeTab, setActiveTab] = useState('pending') const [sourceFilter, setSourceFilter] = useState('all') const [conversationFilter, setConversationFilter] = useState(null) const [pendingCount, setPendingCount] = useState(null) // Detail slide-over (convention 13): id rather than the row object, so the // panel tracks realtime refetches and closes itself when the op leaves the // current list (approved elsewhere, tab switch, filter change). const [detailOpId, setDetailOpId] = useState(null) const [selectedOp, setSelectedOp] = useState(null) const [showCommitDialog, setShowCommitDialog] = useState(false) const [isCommitting, setIsCommitting] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const [showBulkDialog, setShowBulkDialog] = useState(false) const [isBulkCommitting, setIsBulkCommitting] = useState(false) // Reject dialog state: separate from the generic destructive-confirm so we // can ask for a category + free-text reason that feeds back to the agent. // 'bulk' targets the current checkbox selection instead of a single op. const [rejectTarget, setRejectTarget] = useState(null) const [rejectCategory, setRejectCategory] = useState('') const [rejectReason, setRejectReason] = useState('') const [isRejecting, setIsRejecting] = useState(false) const { toast } = useToast() // Read ?conversation= once on mount so deep-links from the agent context // strip filter the list automatically. useEffect(() => { if (typeof window === 'undefined') return const url = new URL(window.location.href) const conv = url.searchParams.get('conversation') if (conv) setConversationFilter(conv) }, []) const fetchOperations = useCallback(async () => { setIsLoading(true) try { if (activeTab === 'pending') { const res = await fetch('/api/pending-operations?status=pending') const json = await res.json() setOperations(json.data ?? []) setPendingCount(json.count ?? json.data?.length ?? 0) } else { // The API is single-status per fetch: Historik merges godkända and // avvisade, newest resolution first. const [committed, rejected] = await Promise.all([ fetch('/api/pending-operations?status=committed').then((r) => r.json()), fetch('/api/pending-operations?status=rejected').then((r) => r.json()), ]) const merged = ([...(committed.data ?? []), ...(rejected.data ?? [])] as PendingOperation[]).sort( (a, b) => (b.resolved_at ?? b.created_at).localeCompare(a.resolved_at ?? a.created_at), ) setOperations(merged) const pc = committed.counts?.pending ?? rejected.counts?.pending if (typeof pc === 'number') setPendingCount(pc) } } catch { toast({ title: 'Kunde inte ladda operationer', variant: 'destructive' }) } setIsLoading(false) }, [activeTab, toast]) useEffect(() => { fetchOperations() }, [fetchOperations]) // Realtime subscription: refetch when ANY pending_operations row changes for // this company. RLS scopes the channel automatically: we don't see other // tenants' events. We refetch the whole list (rather than patching state // in-place) so server-side filtering, sorting, and computed fields stay in // sync with whatever the API route returned, including all tab counts. // Trailing debounce: bulk actions emit one event per row, which previously // stampeded 4 requests per event (list + 3 counts); the burst now collapses // into a single refetch after the last event. useEffect(() => { const supabase = createClient() let debounce: ReturnType | null = null const channel = supabase .channel('pending_operations:list') .on( 'postgres_changes', { event: '*', schema: 'public', table: 'pending_operations' }, () => { if (debounce) clearTimeout(debounce) debounce = setTimeout(() => { fetchOperations() }, 400) } ) .subscribe() return () => { if (debounce) clearTimeout(debounce) void supabase.removeChannel(channel) } }, [fetchOperations]) // Clear selection when filters/tab change useEffect(() => { setSelectedIds(new Set()) }, [activeTab, sourceFilter, conversationFilter]) async function handleCommit() { if (!selectedOp) return setIsCommitting(true) try { const res = await fetch(`/api/pending-operations/${selectedOp.id}/commit`, { method: 'POST' }) const json = await res.json().catch(() => ({})) // getErrorMessage handles both `{ error: string }` and the structured // `{ error: { code, message } }` envelope (the latter would otherwise // toast "[object Object]") and never surfaces raw English. if (!res.ok) throw new Error(getErrorMessage(json, { statusCode: res.status })) toast({ title: 'Godkänd', description: selectedOp.title }) setShowCommitDialog(false) setSelectedOp(null) fetchOperations() } catch (err) { toast({ title: 'Misslyckades', description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel', variant: 'destructive', }) } setIsCommitting(false) } async function handleBulkCommit(ids: string[]) { if (ids.length === 0) return setIsBulkCommitting(true) try { const res = await fetch('/api/pending-operations/bulk-commit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }), }) const json = await res.json().catch(() => ({})) if (!res.ok) throw new Error(getErrorMessage(json, { statusCode: res.status })) const summary = json.data?.summary as | { committed: number; failed: number; skipped: number; rejected: number } | undefined if (summary) { const parts: string[] = [] if (summary.committed > 0) parts.push(`${summary.committed} godkända`) if (summary.failed > 0) parts.push(`${summary.failed} misslyckades`) if (summary.rejected > 0) parts.push(`${summary.rejected} avvisade`) if (summary.skipped > 0) parts.push(`${summary.skipped} hoppades över`) toast({ title: summary.failed > 0 ? 'Klart med fel' : 'Godkänt', description: parts.join(', '), variant: summary.failed > 0 ? 'destructive' : 'default', }) } else { toast({ title: 'Godkänt' }) } setShowBulkDialog(false) setSelectedIds(new Set()) fetchOperations() } catch (err) { toast({ title: 'Misslyckades', description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel', variant: 'destructive', }) } setIsBulkCommitting(false) } function openRejectDialog(target: PendingOperation | 'bulk') { setRejectTarget(target) setRejectCategory('') setRejectReason('') } async function handleReject() { if (!rejectTarget) return setIsRejecting(true) try { const feedback = { ...(rejectCategory ? { rejection_category: rejectCategory } : {}), ...(rejectReason.trim() ? { rejection_reason: rejectReason.trim() } : {}), } if (rejectTarget === 'bulk') { const res = await fetch('/api/pending-operations/bulk-reject', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: Array.from(selectedIds), ...feedback }), }) const json = await res.json().catch(() => ({})) if (!res.ok) throw new Error(getErrorMessage(json, { statusCode: res.status })) const summary = json.data?.summary as | { rejected: number; skipped: number; failed: number } | undefined if (summary) { const parts: string[] = [] if (summary.rejected > 0) parts.push(`${summary.rejected} avvisade`) if (summary.skipped > 0) parts.push(`${summary.skipped} hoppades över`) if (summary.failed > 0) parts.push(`${summary.failed} misslyckades`) toast({ title: summary.failed > 0 ? 'Klart med fel' : 'Avvisade', description: parts.join(', '), variant: summary.failed > 0 ? 'destructive' : 'default', }) } else { toast({ title: 'Avvisade' }) } setSelectedIds(new Set()) } else { const hasFeedback = Object.keys(feedback).length > 0 const res = await fetch(`/api/pending-operations/${rejectTarget.id}/reject`, { method: 'POST', ...(hasFeedback ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(feedback) } : {}), }) if (!res.ok) { const json = await res.json().catch(() => ({})) throw new Error(getErrorMessage(json, { statusCode: res.status })) } toast({ title: 'Avvisad', description: rejectTarget.title }) } setRejectTarget(null) fetchOperations() } catch (err) { toast({ title: 'Kunde inte avvisa', description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel', variant: 'destructive', }) } setIsRejecting(false) } const filteredOperations = operations.filter((op) => { if (conversationFilter && op.agent_metadata?.conversation_id !== conversationFilter) { return false } switch (sourceFilter) { case 'agent': return ( op.actor_type === 'api_key' || op.actor_type === 'mcp_oauth' || op.actor_type === 'cron' || op.actor_type === 'agent_chat' ) case 'high_risk': return op.risk_level === 'high' case 'all': default: return true } }) const showBulkControls = activeTab === 'pending' // Pending ops that meet two criteria: not high risk AND the period covering // them is open. We exclude locked/closed periods from bulk because they will // be rejected at commit time anyway: silently letting the user "select all" // and watching some fail is a worse UX than excluding them up front. const bulkEligible = useMemo( () => filteredOperations.filter((op) => { if (op.status !== 'pending') return false if (op.risk_level === 'high') return false const period = getPeriodStatus(op) if (period && period.status !== 'open') return false return true }), [filteredOperations] ) const bulkEligibleIds = useMemo(() => bulkEligible.map((op) => op.id), [bulkEligible]) const allSelected = bulkEligibleIds.length > 0 && bulkEligibleIds.every((id) => selectedIds.has(id)) const pendingTotal = filteredOperations.filter((op) => op.status === 'pending').length const excludedFromBulk = pendingTotal - bulkEligible.length function toggleSelected(id: string) { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } function toggleSelectAll() { if (allSelected) { setSelectedIds(new Set()) } else { setSelectedIds(new Set(bulkEligibleIds)) } } // "Approve all of this type": find ops with the same operation_type that are bulk-eligible function selectAllOfType(operationType: string) { const ids = bulkEligible .filter((op) => op.operation_type === operationType) .map((op) => op.id) setSelectedIds(new Set(ids)) } // Group counts for type-quick-action buttons (only show if 2+ of same type pending) const typeCounts = useMemo(() => { const counts = new Map() for (const op of bulkEligible) { counts.set(op.operation_type, (counts.get(op.operation_type) ?? 0) + 1) } return Array.from(counts.entries()).filter(([, count]) => count >= 2) }, [bulkEligible]) const selectedCount = selectedIds.size const selectedBreakdown = useMemo(() => { const counts = new Map() for (const op of bulkEligible) { if (selectedIds.has(op.id)) { counts.set(op.operation_type, (counts.get(op.operation_type) ?? 0) + 1) } } return Array.from(counts.entries()).map(([type, count]) => ({ type, count })) }, [bulkEligible, selectedIds]) // Source/kicker line for a row and the detail panel: operation type, // origin (when an agent staged it) and relative age. const sourceLine = (op: PendingOperation) => { const isAgent = op.actor_type && op.actor_type !== 'user' return [ operationLabel(op.operation_type, t), isAgent ? (originLabel(op, t) ?? op.actor_label ?? op.actor_type) : null, formatRelativeTime(op.created_at), ] .filter(Boolean) .join(' · ') } const detailOp = detailOpId ? filteredOperations.find((op) => op.id === detailOpId) ?? null : null const detailPeriod = detailOp ? getPeriodStatus(detailOp) : null const detailPeriodLocked = detailPeriod != null && detailPeriod.status !== 'open' const detailConversationId = detailOp?.agent_metadata?.conversation_id ?? null const SEG_TABS: Array<{ tab: ViewTab; labelKey: string }> = [ { tab: 'pending', labelKey: 'tab_pending' }, { tab: 'history', labelKey: 'tab_history' }, ] return (
{/* Page header (concept scene 11): title + Godkänn alla */}

{t('title')}

{activeTab === 'pending' && bulkEligible.length > 0 && ( )}
{conversationFilter && (
{t('conversation_filter_label')}{' '} #{conversationFilter.slice(0, 8)}
)} {/* Toolbar (concept): status seg left, source picker (convention 8) far right. The count chip rides only on Väntar: it is the queue. */}
{SEG_TABS.map(({ tab, labelKey }) => ( ))}
setSourceFilter(id as SourceFilter)} triggerLabel={sourceFilterLabels(t)[sourceFilter]} items={[ { id: 'all', label: t('tab_all') }, { id: 'agent', label: t('tab_agent') }, { id: 'high_risk', label: t('tab_high_risk') }, ]} />
{/* Bulkbar (concept): hidden until at least one operation is selected via the hover checkboxes, then it pops in with the count, the batch actions, and the selection shortcuts as quiet links. */} {showBulkControls && selectedCount > 0 && (
{selectedCount}{' '} {t('bulkbar_selected', { count: selectedCount })} {typeCounts.length >= 2 && typeCounts.map(([type, count]) => ( ))} {!allSelected && ( )} {excludedFromBulk > 0 && ( {t('bulk_excluded_note', { count: excludedFromBulk })} )}
)} {isLoading ? ( ) : filteredOperations.length === 0 ? ( activeTab === 'pending' ? ( /* Concept empty state: the queue is the good news. */

{t('empty_pending_title')}

{t('empty_pending_description')}

) : ( } title={t('empty_history_title')} description={t('empty_finished_description')} /> ) ) : (
{filteredOperations.map((op) => { const period = getPeriodStatus(op) const periodLocked = period != null && period.status !== 'open' const canBulkSelect = showBulkControls && op.status === 'pending' && op.risk_level !== 'high' && !periodLocked const isSelected = selectedIds.has(op.id) const isAgent = op.actor_type && op.actor_type !== 'user' const warningSentence = singleActionWarning(op.operation_type) const showHighRiskWarning = op.risk_level === 'high' && warningSentence && op.status === 'pending' if (op.status !== 'pending') { // Historik row (concept k-row): status chip + text + sub line. const resolvedAt = op.resolved_at ?? op.created_at const sub = [ formatRelativeTime(resolvedAt), operationLabel(op.operation_type, t), originLabel(op, t), ] .filter(Boolean) .join(' · ') return (
setDetailOpId(op.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() setDetailOpId(op.id) } }} className={cn( 'group flex cursor-pointer items-start gap-3 border-b border-border px-1 py-3 transition-colors duration-150', detailOpId === op.id ? 'bg-secondary/25' : 'hover:bg-secondary/35', )} > {isAutoExpired(op) ? t('badge_auto_expired') : op.status === 'committed' ? t('badge_approved') : op.status === 'failed_partial' ? t('badge_failed_partial') : t('badge_rejected')}
{op.title}
{sub} {op.status === 'rejected' && op.rejection_reason ? ` · ${t('history_reason', { reason: op.rejection_reason })}` : ''}
{op.status === 'failed_partial' && (

{t('failed_partial_detail')} {failedPartialPostedIds(op) && ( ({failedPartialPostedIds(op)}) )}

)}
) } return (
setDetailOpId(op.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() setDetailOpId(op.id) } }} className={cn( 'group flex cursor-pointer items-start gap-3 border-b border-border px-1 py-4 transition-colors duration-150', detailOpId === op.id ? 'bg-secondary/25' : 'hover:bg-secondary/35', isSelected && 'bg-secondary/40', )} > {/* Hover-revealed selection checkbox (concept .cb) */} e.stopPropagation()} > {canBulkSelect && ( toggleSelected(op.id)} aria-label={t('select_operation_aria')} className={cn( 'duration-150', isSelected ? 'opacity-100' : HOVER_REVEAL_CLASS, )} /> )} {/* Actor column with the curved thread (concept op-thread): drops from the icon and elbows toward the action row. */} {isAgent ? : }
{sourceLine(op)}
{op.title}
{showHighRiskWarning && (

{warningSentence}

)} {/* Action pills under the text (concept gact) */}
{/* Risk chip (concept op-risk) */} {op.risk_level === 'high' ? t('badge_high_risk') : op.risk_level === 'medium' ? t('badge_medium_risk') : t('badge_low_risk')}
) })}
)}
{/* Detail slide-over (convention 13): the review surface. Derived from the live list, so a realtime refetch that resolves the op closes it. */} { if (!open) setDetailOpId(null) }} > {detailOp && ( <> {detailPeriodLocked && detailPeriod && detailOp.status === 'pending' && ( )} {/* The operation itself in its own box (concept): what the agent is about to do, clearly framed. */}
{detailOp.status === 'pending' && singleActionWarning(detailOp.operation_type) && (

{singleActionWarning(detailOp.operation_type)}

)} {detailOp.status === 'rejected' && detailOp.rejection_category && (

Avvisad: {REJECTION_CATEGORY_LABELS[detailOp.rejection_category]} {detailOp.rejection_reason ? `, "${detailOp.rejection_reason}"` : ''}

)} {isAutoExpired(detailOp) && (

{t('auto_expired_detail')}

)} {detailOp.status === 'failed_partial' && (

{t('failed_partial_detail')} {failedPartialPostedIds(detailOp) && ( ({failedPartialPostedIds(detailOp)}) )}

)}
{detailConversationId && ( )} {detailOp.status === 'pending' && ( <> )} )}
{/* Commit confirmation dialog */} {selectedOp && } {/* Bulk commit confirmation dialog */} handleBulkCommit(Array.from(selectedIds))} >

{t('bulk_confirm_intro')}

    {selectedBreakdown.map(({ type, count }) => (
  • {bulkActionLabel(type, count, t)} {count}
  • ))}

{t('bulk_confirm_footer')}

{/* Reject dialog: category + free-text reason. Both optional so the user can still reject quickly without filling anything in. Doubles as the bulk-reject confirmation; the feedback then applies to every selected op. */} { if (!open) setRejectTarget(null) }}> {rejectTarget === 'bulk' ? t('reject_bulk_title', { count: selectedCount }) : 'Avvisa operation'} {rejectTarget === 'bulk' ? t('reject_bulk_description') : rejectTarget?.title}