'use client' import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useCompanySettings } from '@/lib/reference-data/hooks' import { useRouter } from 'next/navigation' 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 { SegmentedControl } from '@/components/ui/segmented-control' import { CHECKBOX_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' import { useRangeSelect } from '@/lib/hooks/use-range-select' 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 { ToastAction } from '@/components/ui/toast' import { cn, formatDate } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' import { createClient } from '@/lib/supabase/client' import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode' import { ClipboardCheck, Bot, Check, ChevronRight, Info, Loader2, Lock, MessageSquare, AlertTriangle, X, ArrowDownUp, } from 'lucide-react' import type { PendingOperation, PendingOperationRejectionCategory, } from '@/types' import { OperationPreview, AccountNamesContext } from '@/components/pending-operations/OperationPreview' import { useAccountNamesSource } from '@/components/pending-operations/use-account-names' import { operationLabel, singleActionWarning, REJECTION_CATEGORY_LABELS, } from '@/components/pending-operations/vocabulary' // 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)}` } // 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 SORT_ORDER_STORAGE_KEY = 'pending.sortOrder' 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' /** * 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` } /** * Account number -> account name, for the proposal previews. * * A preview line showed the account number next to the line's own description, * so "5890 Utlägg Norwegian" hid the fact that 5890 is Övriga resekostnader. * The number alone is not readable and the description is not the account, so * approving meant trusting a label that never named what was being debited. * * Owned by the page rather than a module-level cache: the map is per company, * and a cache that outlives the page would keep serving one company's account * names after a switch. A failed fetch leaves the map empty, which shows the * bare number rather than a wrong name, and retries on the next mount. */ /** * 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 router = useRouter() const accountNames = useAccountNamesSource() const [operations, setOperations] = useState([]) const [isLoading, setIsLoading] = useState(true) const [activeTab, setActiveTab] = useState('pending') const [sourceFilter, setSourceFilter] = useState('all') // Queue order. Newest first by default; a bokslut batch is approved oldest // first, so the choice is remembered per browser. const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') useEffect(() => { try { const stored = window.localStorage.getItem(SORT_ORDER_STORAGE_KEY) if (stored === 'asc' || stored === 'desc') setSortOrder(stored) } catch { // Storage blocked: keep the default. } }, []) const toggleSortOrder = () => { setSortOrder((prev) => { const next = prev === 'desc' ? 'asc' : 'desc' try { window.localStorage.setItem(SORT_ORDER_STORAGE_KEY, next) } catch { // Storage blocked: the toggle still applies for this session. } return next }) } 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()) // Radix' onCheckedChange carries no mouse event: the preceding click records // whether shift was held, for range selection. const shiftHeld = useRef(false) 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() // Whether the "Bokför utkasten" toast CTA leads anywhere: bulk Bokför on // /invoices only selects drafts when the company books at issue. Under // kontantmetoden or deferred booking (#967) the CTA would be a dead end, // so it stays suppressed (false until settings load: suppressing is the // safe direction, the neutral hint sentence still shows). // Derived from the session-cached settings row (lib/reference-data); false // until the row is available, suppressing being the safe direction. const { settings: companySettings } = useCompanySettings() const invoiceDraftsCtaUseful = companySettings ? booksInvoicesOnIssue(companySettings) : false // 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) }, []) // Which tab the rows in `operations` belong to, and whether anything is on // screen: lets refetches decide between the first-load takeover and a // background reconcile without making row state a useCallback dependency // (which would re-subscribe the realtime channel on every data change). const loadedTabRef = useRef(null) const hasRowsRef = useRef(false) useEffect(() => { hasRowsRef.current = operations.length > 0 }, [operations]) // Background reconcile in flight: drives the quiet toolbar cue. const [isRefreshing, setIsRefreshing] = useState(false) // Monotonic request sequence: a fetch kicked off for a previous tab (or an // older realtime echo) can resolve after the current one. Only the latest // request may touch rows, counts, refs, toasts, or loading cues; stale // responses bail out and leave the newer request's state alone. const fetchSequenceRef = useRef(0) const fetchOperations = useCallback(async () => { const sequence = ++fetchSequenceRef.current const isCurrent = () => sequence === fetchSequenceRef.current // The list-for-spinner swap is reserved for a first load or a tab whose // rows aren't on screen yet. Approving/rejecting a row (and its realtime // echo) used to run list → spinner → list → spinner → list: a whole-page // layout collapse plus a full stagger-enter replay, twice, for a // single-row change. const takeover = !hasRowsRef.current || loadedTabRef.current !== activeTab if (takeover) setIsLoading(true) else setIsRefreshing(true) try { if (activeTab === 'pending') { const res = await fetch(`/api/pending-operations?status=pending&order=${sortOrder}`) // A JSON error body parses fine but carries no data: without this // check a failed refresh would blank the list and zero the badge // instead of keeping current rows and surfacing the error toast. if (!res.ok) throw new Error(`HTTP ${res.status}`) const json = await res.json() if (!isCurrent()) return setOperations(json.data ?? []) setPendingCount(json.count ?? json.data?.length ?? 0) loadedTabRef.current = 'pending' } else { // The API is single-status per fetch: Historik merges godkända and // avvisade, newest resolution first. const [committedRes, rejectedRes] = await Promise.all([ fetch('/api/pending-operations?status=committed'), fetch('/api/pending-operations?status=rejected'), ]) if (!committedRes.ok || !rejectedRes.ok) { throw new Error(`HTTP ${committedRes.status}/${rejectedRes.status}`) } const [committed, rejected] = await Promise.all([committedRes.json(), rejectedRes.json()]) if (!isCurrent()) return 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) loadedTabRef.current = 'history' } } catch { if (!isCurrent()) return toast({ title: 'Kunde inte ladda operationer', variant: 'destructive' }) // The rows on screen belong to another tab (or no load has succeeded // yet): dropping the loading state here would render those foreign rows // under this tab's header as if they were its content. Hold the loading // state instead; the toast says why, and the next tab switch or // realtime echo retries. if (loadedTabRef.current !== activeTab) return } if (!isCurrent()) return setIsLoading(false) setIsRefreshing(false) }, [activeTab, sortOrder, 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]) // Shared by the direct-commit pill (low/medium risk) and the high-risk // confirmation dialog. The Granskning row already states source, title and // risk and offers Detaljer, so for low/medium the pill IS the deliberate // approval; only high risk keeps the dialog, whose warning sentence carries // information the row does not. async function commitOp(op: PendingOperation) { setIsCommitting(true) try { const res = await fetch(`/api/pending-operations/${op.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: op.title }) setShowCommitDialog(false) setSelectedOp(null) // Drop the committed op from the bulk selection: the row leaves the // pending list on refresh, but a stale id would keep inflating the // bulk bar and ride along into bulk-commit. setSelectedIds((prev) => { if (!prev.has(op.id)) return prev const next = new Set(prev) next.delete(op.id) return next }) fetchOperations() } catch (err) { toast({ title: 'Misslyckades', description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel', variant: 'destructive', }) } setIsCommitting(false) } async function handleCommit() { if (!selectedOp) return await commitOp(selectedOp) } 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 // Committed create_invoice ops land as unnumbered DRAFTS: point the // user at the draft view where bulk Bokför finishes the job. const results = (json.data?.results ?? []) as Array<{ id: string; status: string }> const committedIds = new Set( results.filter((r) => r.status === 'committed').map((r) => r.id), ) const committedInvoiceDrafts = operations.some( (op) => committedIds.has(op.id) && op.operation_type === 'create_invoice', ) const draftsCta = committedInvoiceDrafts && invoiceDraftsCtaUseful ? { action: ( router.push('/invoices?status=draft')} > {t('bulk_invoice_drafts_cta')} ), } : {} 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: committedInvoiceDrafts ? `${parts.join(', ')}. ${t('bulk_invoice_drafts_hint')}` : parts.join(', '), variant: summary.failed > 0 ? 'destructive' : 'default', ...draftsCta, }) } else { toast({ title: 'Godkänt', ...draftsCta }) } 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 // Ranges walk the bulk-eligible operations in rendered order. const range = useRangeSelect({ visibleIds: bulkEligibleIds, selectedIds, setSelectedIds, }) function toggleSelected(id: string, extend?: boolean) { range.toggle(id, extend) } function toggleSelectAll() { if (allSelected) { setSelectedIds(new Set()) } else { setSelectedIds(new Set(bulkEligibleIds)) } range.resetAnchor() } // "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)) range.resetAnchor() } // 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. */}
({ value: tab, label: t(labelKey), count: tab === 'pending' ? pendingCount ?? 0 : undefined, }))} /> {activeTab === 'pending' && ( )} {/* Quiet cue that a background reconcile is running (post-action or realtime): the list itself never swaps to a spinner for it. */} {isRefreshing && !isLoading && ( )}
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', )} > {/* Same actor mark as the pending rows, minus the curved op-thread (it points at an action row history lacks). */} {isAgent ? : } {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', )} > {/* Always-visible selection checkbox (concept .cb) */} e.stopPropagation()} > {canBulkSelect && ( { shiftHeld.current = e.shiftKey }} onCheckedChange={() => toggleSelected(op.id, shiftHeld.current)} aria-label={t('select_operation_aria')} className={cn( 'border-foreground duration-150', isSelected ? 'opacity-100' : CHECKBOX_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. */}
{/* The agent's audit-trail note for this operation (the `notes` tool input): the approver should read why, not only what. */} {typeof detailOp.params?.notes === 'string' && detailOp.params.notes.trim() !== '' && (

{t('note_label')}

{detailOp.params.notes}

)} {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'} {/* data-ph-mask: the operation title carries counterparty and amount */} {rejectTarget === 'bulk' ? t('reject_bulk_description') : rejectTarget?.title}