Files
accounted/app/(dashboard)/pending/page.tsx
T
Jakob WennbergandClaude Opus 4.6 f3ae3cd361 feat: event log, pending operations, and MCP staging (#135)
* feat: event log, pending operations, and MCP staging

- Event log system: persist bus events to event_log table for external
  automation platforms. Batch insert for transaction.synced. Daily
  cleanup cron at 02:00 UTC.
- Pending operations: MCP write tools (categorize, create customer,
  create invoice) now stage to pending_operations instead of executing
  directly. Users review and commit/reject from /pending in the web UI.
- Granskning page: card-based review UI with expandable previews,
  commit/reject dialogs. Only shown in nav when pending ops exist.
- Commit route re-executes using core lib functions (no extension
  imports). Guards against stale state (double-commit, deleted entities).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: stage new MCP write tools after main merge

Add staging for 4 new write tools from #133:
- mark_invoice_paid, send_invoice, mark_invoice_sent,
  match_transaction_invoice
- Expand pending_operations CHECK constraint
- Add commit executors with full execution logic
- Add UI labels and generic preview component
- Remove confirm parameter from categorize (single-call staging)
- Fix UUID in pending op title (fetch transaction description)
- Hide Granskning nav when no pending ops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback

- Fix TS build error: use `select('*, customer:customers(*)')` for
  match_transaction_invoice to avoid array type inference
- Add status guard to commitSendInvoice (prevents duplicate sends)
- Replace auth.admin.getUserById with user email from session auth
- Restore optimistic lock check in commitMatchTransactionInvoice
- Fix tool description typo: expense_software → expense_office

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:07:36 +01:00

382 lines
15 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback, Fragment } from 'react'
import { PageHeader } from '@/components/ui/page-header'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import {
ClipboardCheck,
Loader2,
ArrowLeftRight,
Users,
Receipt,
CheckCircle2,
XCircle,
} from 'lucide-react'
import type { PendingOperation, PendingOperationStatus } from '@/types'
const operationLabels: Record<string, { label: string; icon: typeof ArrowLeftRight; variant: 'default' | 'secondary' | 'outline' }> = {
categorize_transaction: { label: 'Kategorisering', icon: ArrowLeftRight, variant: 'default' },
create_customer: { label: 'Ny kund', icon: Users, variant: 'secondary' },
create_invoice: { label: 'Ny faktura', icon: Receipt, variant: 'outline' },
mark_invoice_paid: { label: 'Betald faktura', icon: Receipt, variant: 'default' },
send_invoice: { label: 'Skicka faktura', icon: Receipt, variant: 'outline' },
mark_invoice_sent: { label: 'Markera skickad', icon: Receipt, variant: 'outline' },
match_transaction_invoice: { label: 'Fakturamatchning', icon: ArrowLeftRight, variant: 'secondary' },
}
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<string, unknown> }) {
const vatLines = (data.vat_lines as Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>) || []
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Debetkonto</span>
<span className="font-mono">{String(data.debit_account ?? '')}</span>
<span className="text-muted-foreground">Kreditkonto</span>
<span className="font-mono">{String(data.credit_account ?? '')}</span>
<span className="text-muted-foreground">Belopp</span>
<span className="font-mono tabular-nums">
{formatCurrency(data.amount as number, (data.currency as string) || 'SEK')}
</span>
</div>
{vatLines.length > 0 && (
<div className="border-t pt-2">
<p className="text-xs text-muted-foreground mb-1">Momsrader</p>
{vatLines.map((line, i) => (
<div key={i} className="flex justify-between font-mono text-xs">
<span>{line.account_number} {line.description}</span>
<span className="tabular-nums">
{line.debit_amount > 0 ? `D ${formatCurrency(line.debit_amount)}` : `K ${formatCurrency(line.credit_amount)}`}
</span>
</div>
))}
</div>
)}
</div>
)
}
function CustomerPreview({ data }: { data: Record<string, unknown> }) {
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<span className="text-muted-foreground">Namn</span>
<span>{String(data.name ?? '')}</span>
<span className="text-muted-foreground">Typ</span>
<span>{String(data.customer_type ?? '')}</span>
{data.email ? (
<>
<span className="text-muted-foreground">E-post</span>
<span>{String(data.email)}</span>
</>
) : null}
{data.org_number ? (
<>
<span className="text-muted-foreground">Org.nr</span>
<span className="font-mono">{String(data.org_number)}</span>
</>
) : null}
</div>
)
}
function InvoicePreview({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate: number }>) || []
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Kund</span>
<span>{String(data.customer_name ?? '')}</span>
<span className="text-muted-foreground">Datum</span>
<span>{String(data.invoice_date ?? '')}</span>
<span className="text-muted-foreground">Förfallodatum</span>
<span>{String(data.due_date ?? '')}</span>
</div>
{items.length > 0 && (
<div className="border-t pt-2 space-y-1">
{items.map((item, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="truncate mr-4">{item.description} ({item.quantity} {item.unit})</span>
<span className="font-mono tabular-nums whitespace-nowrap">
{formatCurrency(item.line_total, (data.currency as string) || 'SEK')}
</span>
</div>
))}
</div>
)}
<div className="border-t pt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Netto</span>
<span className="font-mono tabular-nums text-right">{formatCurrency(data.subtotal as number, (data.currency as string) || 'SEK')}</span>
<span className="text-muted-foreground">Moms</span>
<span className="font-mono tabular-nums text-right">{formatCurrency(data.vat_amount as number, (data.currency as string) || 'SEK')}</span>
<span className="font-medium">Totalt</span>
<span className="font-mono tabular-nums font-medium text-right">{formatCurrency(data.total as number, (data.currency as string) || 'SEK')}</span>
</div>
</div>
)
}
function GenericPreview({ data }: { data: Record<string, unknown> }) {
const entries = Object.entries(data).filter(([, v]) => v != null && v !== '')
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{entries.map(([key, value]) => (
<Fragment key={key}>
<span className="text-muted-foreground">{key.replace(/_/g, ' ')}</span>
<span className={typeof value === 'number' ? 'font-mono tabular-nums' : ''}>
{String(value)}
</span>
</Fragment>
))}
</div>
)
}
function OperationPreview({ op }: { op: PendingOperation }) {
switch (op.operation_type) {
case 'categorize_transaction':
return <CategorizePreview data={op.preview_data} />
case 'create_customer':
return <CustomerPreview data={op.preview_data} />
case 'create_invoice':
return <InvoicePreview data={op.preview_data} />
default:
return <GenericPreview data={op.preview_data} />
}
}
export default function PendingOperationsPage() {
const [operations, setOperations] = useState<PendingOperation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<PendingOperationStatus>('pending')
const [expandedId, setExpandedId] = useState<string | null>(null)
const [selectedOp, setSelectedOp] = useState<PendingOperation | null>(null)
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [isCommitting, setIsCommitting] = useState(false)
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
const fetchOperations = useCallback(async () => {
setIsLoading(true)
try {
const res = await fetch(`/api/pending-operations?status=${activeTab}`)
const json = await res.json()
setOperations(json.data ?? [])
} catch {
toast({ title: 'Kunde inte ladda operationer', variant: 'destructive' })
}
setIsLoading(false)
}, [activeTab, toast])
useEffect(() => {
fetchOperations()
}, [fetchOperations])
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()
if (!res.ok) throw new Error(json.error || 'Misslyckades')
toast({ title: 'Godkänd', description: selectedOp.title })
setShowCommitDialog(false)
setSelectedOp(null)
fetchOperations()
} catch (err) {
toast({
title: 'Misslyckades',
description: err instanceof Error ? err.message : 'Okänt fel',
variant: 'destructive',
})
}
setIsCommitting(false)
}
async function handleReject(op: PendingOperation) {
const ok = await confirm({
title: 'Avvisa operation?',
description: `"${op.title}" kommer att avvisas.`,
confirmLabel: 'Avvisa',
variant: 'destructive',
})
if (!ok) return
try {
const res = await fetch(`/api/pending-operations/${op.id}/reject`, { method: 'POST' })
if (!res.ok) throw new Error('Misslyckades')
toast({ title: 'Avvisad', description: op.title })
fetchOperations()
} catch {
toast({ title: 'Kunde inte avvisa', variant: 'destructive' })
}
}
const warningForType: Record<string, string> = {
categorize_transaction: '',
create_customer: '',
create_invoice: '',
}
return (
<div className="space-y-6">
<PageHeader
title="Granskning"
description="Operationer från din AI-agent som väntar på godkännande"
/>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as PendingOperationStatus)}>
<TabsList>
<TabsTrigger value="pending">Väntande</TabsTrigger>
<TabsTrigger value="committed">Godkända</TabsTrigger>
<TabsTrigger value="rejected">Avvisade</TabsTrigger>
</TabsList>
</Tabs>
{isLoading ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : operations.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted mb-4">
<ClipboardCheck className="h-6 w-6 text-muted-foreground" />
</div>
<p className="font-medium">
{activeTab === 'pending'
? 'Inga väntande operationer'
: activeTab === 'committed'
? 'Inga godkända operationer'
: 'Inga avvisade operationer'}
</p>
<p className="text-sm text-muted-foreground mt-1">
{activeTab === 'pending'
? 'När din AI-agent skapar bokföring visas den här för granskning.'
: 'Historik för AI-agentens operationer visas här.'}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{operations.map((op) => {
const config = operationLabels[op.operation_type] || { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const }
const isExpanded = expandedId === op.id
return (
<Card
key={op.id}
className="transition-colors hover:border-primary/30"
>
<CardContent className="py-4">
<div
className="flex items-start justify-between gap-4 cursor-pointer"
onClick={() => setExpandedId(isExpanded ? null : op.id)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant={config.variant}>{config.label}</Badge>
{op.status === 'committed' && (
<Badge variant="default" className="bg-emerald-500/10 text-emerald-600 border-emerald-200">
<CheckCircle2 className="h-3 w-3 mr-1" />
Godkänd
</Badge>
)}
{op.status === 'rejected' && (
<Badge variant="destructive" className="bg-destructive/10">
<XCircle className="h-3 w-3 mr-1" />
Avvisad
</Badge>
)}
<span className="text-xs text-muted-foreground">
{formatRelativeTime(op.created_at)}
</span>
</div>
<p className="text-sm font-medium truncate">{op.title}</p>
</div>
{op.status === 'pending' && (
<div className="flex gap-2 flex-shrink-0">
<Button
size="sm"
className="h-8 px-3 text-xs"
onClick={(e) => {
e.stopPropagation()
setSelectedOp(op)
setShowCommitDialog(true)
}}
>
Godkänn
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 px-3 text-xs"
onClick={(e) => {
e.stopPropagation()
handleReject(op)
}}
>
Avvisa
</Button>
</div>
)}
</div>
{/* Expandable preview */}
<div className={`grid transition-all duration-200 ${isExpanded ? 'grid-rows-[1fr] mt-3' : 'grid-rows-[0fr]'}`}>
<div className="overflow-hidden">
<div className="border-t pt-3">
<OperationPreview op={op} />
</div>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* Commit confirmation dialog */}
<ConfirmationDialog
open={showCommitDialog}
onOpenChange={setShowCommitDialog}
title={selectedOp?.title || 'Godkänn operation'}
warningText={selectedOp ? warningForType[selectedOp.operation_type] : ''}
confirmLabel="Godkänn"
isSubmitting={isCommitting}
onConfirm={handleCommit}
>
{selectedOp && <OperationPreview op={selectedOp} />}
</ConfirmationDialog>
{/* Reject confirmation dialog */}
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}