feat: arcim inbox (Resend Inbound) + smart-match extension + commit metadata (#286)

* feat: multi-series SIE import, reusable FiscalYearSelector, library templates in picker

- SIE import preserves each voucher's source series (B/C/I/V/...), essential
  for Fortnox migrations where series carry semantic meaning (kundfakturor,
  inbetalningar, etc.). Target numbering still goes through next_voucher_number
  per series; source (series, number) is stored in the migration mapping for
  BFNAR 2013:2 audit trail.
- Execute route reads company_settings.default_voucher_series as the fallback
  for vouchers arriving without a series (SIE4I).
- Extract shared FiscalYearSelector component; adopt in /reports and
  /bookkeeping.
- Transaction TemplatePicker now surfaces user-created library templates
  (company + team scope) alongside the static registry, with a helper to
  convert simple library templates into the BookingTemplate shape.
- Exclude 8999 "Årets resultat" from income statement financial section and
  monthly breakdown so year-end closing entries don't cancel the net result.

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

* test: skip Bokio SIE regression when fixtures are absent

/dev_docs is gitignored (contains anonymised customer exports), so the
integration test can't find its input files in CI. Gate the suite on
fixture presence so it still runs locally.

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

* fix: address Greptile review feedback

- convertLibraryToBookingTemplate: default entity_applicability to 'all'
  when the source template has no entity_type, so TemplatePicker doesn't
  silently hide it for companies with a set entity type.
- FiscalYearSelector: fire onReady in the no-company early-return branch
  so consumers (e.g. ReportsPage) don't get stuck in a loading skeleton
  while the company context is still hydrating.

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

* feat: arcim inbox + smart-match extension + commit metadata

Three threads, all gated off in extensions.config.json (invoice-inbox and
inbox-smart-match are not in the enabled list for this PR).

invoice-inbox: Gmail OAuth -> Resend Inbound (v2.0.0)
- Remove gmail-scanner / gmail-helpers
- Add resend-inbound.ts (webhook verify, attachment fetch) and
  inbox-provisioning.ts (per-company @arcim.io address with rotation)
- Replace /gmail/* routes with /inbox/address and admin-only /inbox/rotate
- Workspace UI: card layout + MatchBlock surfacing AI transaction matches
- classify-document: tightened discount/total prompt; cap confidence at
  50% when line items do not reconcile with amount_incl_vat
- Manifest requires RESEND_API_KEY, RESEND_INBOUND_DOMAIN,
  RESEND_INBOUND_WEBHOOK_SECRET

inbox-smart-match (new extension)
- Event-driven AI matching of receipts to bank transactions
- Listens on inbox_item.classified (match now) and transaction.synced
  (retro-match receipts waiting for a transaction)
- Uses service-role client; processing_history append is scoped by
  company_id from the event payload

commit metadata + audit plumbing
- journal_entries gains commit_method and rubric_version columns
- commit_journal_entry RPC accepts both (BFNAR 2013:2 behandlingshistorik)
- processing-history PII detector strips UUID-shaped substrings before
  personnummer pattern matching (UUIDs were triggering false positives)
- New generic inbox_item.classified event

Migrations
- arcim_inbox: company_inboxes table, resend_email_id, email_body_text,
  auto-provision trigger, drops obsolete email_connections
- journal_entry_commit_metadata: new columns + updated RPC
- inbox_attachment_composite: resend_attachment_id + composite unique index
- inbox_smart_match: correlation_id, match_reasoning, expanded match_method
  CHECK, pending-match and correlation indexes

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-20 21:50:07 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent dfb953638c
commit 0076aa85f8
31 changed files with 2614 additions and 899 deletions
@@ -1,7 +1,7 @@
'use client'
import { useState, useCallback, useEffect, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
@@ -14,14 +14,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
@@ -37,6 +29,7 @@ import {
Upload,
Mail,
FileText,
Receipt as ReceiptIcon,
RefreshCw,
Check,
X,
@@ -44,10 +37,14 @@ import {
Loader2,
Plus,
Trash2,
Copy,
RotateCcw,
ArrowRight,
Sparkles,
} from 'lucide-react'
import Link from 'next/link'
import { useSearchParams, useRouter } from 'next/navigation'
import { formatCurrency, formatDate } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InvoiceExtractionResult } from '@/types'
@@ -66,6 +63,17 @@ interface InboxItem {
email_from: string | null
email_subject: string | null
error_message: string | null
matched_transaction_id: string | null
match_confidence: number | null
match_method: string | null
match_reasoning: string | null
matched_transaction: {
id: string
description: string | null
amount: number
currency: string
date: string
} | null
}
interface Supplier {
@@ -129,13 +137,6 @@ const VAT_OPTIONS = [
// ── Helpers ──────────────────────────────────────────────────
function confidenceBadge(confidence: number | null) {
if (confidence === null) return null
if (confidence >= 0.9) return <Badge variant="success">Hög</Badge>
if (confidence >= 0.7) return <Badge variant="warning">Medium</Badge>
return <Badge variant="destructive">Låg</Badge>
}
function extractSupplierName(item: InboxItem): string | null {
if (item.document_type !== 'supplier_invoice' || !item.extracted_data) return null
return item.extracted_data.supplier?.name || null
@@ -155,6 +156,96 @@ function extractCurrency(item: InboxItem): string {
return (invoice?.currency as string) || (receipt?.currency as string) || 'SEK'
}
type ConfidenceLevel = 'high' | 'medium' | 'low'
function confidenceLevel(conf: number | null): ConfidenceLevel {
if (conf == null) return 'low'
if (conf >= 0.85) return 'high'
if (conf >= 0.6) return 'medium'
return 'low'
}
function MatchBlock({ item }: { item: InboxItem }) {
if (item.document_type !== 'receipt') return null
const isMatching = item.match_method === null && item.status === 'ready'
const isPending = item.match_method === 'pending_transaction'
const isMatched = !!item.matched_transaction_id && !!item.matched_transaction
if (isMatching) {
return (
<div className="mt-2.5 flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>AI letar efter matchande transaktion…</span>
</div>
)
}
if (isPending) {
return (
<div className="mt-2.5 flex items-center gap-2 text-xs text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 shrink-0" />
<span>Inväntar matchande banktransaktion</span>
{item.match_reasoning && (
<InfoTooltip content={item.match_reasoning} side="right" maxWidth="340px" />
)}
</div>
)
}
if (isMatched && item.matched_transaction) {
const tx = item.matched_transaction
const confidencePct = item.match_confidence != null ? Math.round(item.match_confidence * 100) : null
const level = confidenceLevel(item.match_confidence)
return (
<div
className={cn(
'mt-2.5 rounded-md border px-3 py-2.5',
level === 'high' && 'border-emerald-500/25 bg-emerald-500/5',
level === 'medium' && 'border-amber-500/25 bg-amber-500/5',
level === 'low' && 'border-red-500/25 bg-red-500/5'
)}
>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0 text-sm">
<ArrowRight className={cn(
'h-3.5 w-3.5 shrink-0',
level === 'high' && 'text-emerald-700 dark:text-emerald-400',
level === 'medium' && 'text-amber-700 dark:text-amber-500',
level === 'low' && 'text-red-700 dark:text-red-400'
)} />
<span className="truncate font-medium">{tx.description || 'Matchad transaktion'}</span>
<span className="text-muted-foreground">·</span>
<span className="tabular-nums shrink-0">{formatCurrency(Math.abs(tx.amount), tx.currency)}</span>
<span className="text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">{tx.date}</span>
</div>
{confidencePct != null && (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums shrink-0',
level === 'high' && 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
level === 'medium' && 'bg-amber-500/15 text-amber-700 dark:text-amber-300',
level === 'low' && 'bg-red-500/15 text-red-700 dark:text-red-300'
)}
>
<Sparkles className="h-3 w-3" />
{confidencePct}%
</span>
)}
</div>
{item.match_reasoning && (
<p className="mt-1.5 text-xs text-muted-foreground leading-relaxed">
{item.match_reasoning}
</p>
)}
</div>
)
}
return null
}
function timeAgo(isoDate: string): string {
const diff = Date.now() - new Date(isoDate).getTime()
const minutes = Math.floor(diff / 60000)
@@ -223,23 +314,22 @@ function WorkspaceSkeleton() {
// ── Main Component ───────────────────────────────────────────
export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProps) {
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const { toast } = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const searchParams = useSearchParams()
const router = useRouter()
const [items, setItems] = useState<InboxItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState('all')
const [isScanning, setIsScanning] = useState(false)
const [isUploading, setIsUploading] = useState(false)
// Gmail connection state
const [gmailConnection, setGmailConnection] = useState<{
email_address: string; status: string; last_sync_at: string | null
// Arcim inbox state
const [inboxAddress, setInboxAddress] = useState<{
address: string
local_part: string
status: string
} | null>(null)
const [isConnectingGmail, setIsConnectingGmail] = useState(false)
const [isRotating, setIsRotating] = useState(false)
// Convert dialog state
const [convertItem, setConvertItem] = useState<InboxItem | null>(null)
@@ -275,55 +365,66 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
}
}, [statusFilter, toast])
const fetchGmailStatus = useCallback(async () => {
const fetchInboxAddress = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/status')
if (!res.ok) return
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox/address')
if (!res.ok) {
setInboxAddress(null)
return
}
const { data } = await res.json()
const active = data?.connections?.find((c: { status: string }) => c.status === 'active')
setGmailConnection(active || null)
setInboxAddress(data || null)
} catch { /* silent */ }
}, [])
const handleConnectGmail = useCallback(async () => {
setIsConnectingGmail(true)
const handleCopyAddress = useCallback(async () => {
if (!inboxAddress?.address) return
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/auth')
if (!res.ok) throw new Error('Failed to get auth URL')
const { data } = await res.json()
window.location.href = data.authUrl
await navigator.clipboard.writeText(inboxAddress.address)
toast({ title: 'Adress kopierad' })
} catch {
toast({ title: 'Kunde inte starta Gmail-koppling', variant: 'destructive' })
setIsConnectingGmail(false)
toast({ title: 'Kunde inte kopiera', variant: 'destructive' })
}
}, [toast])
}, [inboxAddress, toast])
const handleDisconnectGmail = useCallback(async () => {
const handleRotateAddress = useCallback(async () => {
if (!inboxAddress) return
const confirmed = window.confirm(
'Den gamla adressen slutar ta emot e-post direkt. Leverantörer som använder den måste uppdateras. Vill du fortsätta?'
)
if (!confirmed) return
setIsRotating(true)
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/disconnect', { method: 'POST' })
if (!res.ok) throw new Error('Failed to disconnect')
setGmailConnection(null)
toast({ title: 'Gmail frånkopplad' })
} catch {
toast({ title: 'Kunde inte koppla från Gmail', variant: 'destructive' })
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox/rotate', { method: 'POST' })
if (!res.ok) {
const { error } = await res.json().catch(() => ({ error: 'Rotation misslyckades' }))
throw new Error(error)
}
const { data } = await res.json()
setInboxAddress(data)
toast({ title: 'Ny adress skapad' })
} catch (err) {
toast({ title: err instanceof Error ? err.message : 'Rotation misslyckades', variant: 'destructive' })
} finally {
setIsRotating(false)
}
}, [toast])
}, [inboxAddress, toast])
useEffect(() => {
fetchItems()
fetchGmailStatus()
fetchInboxAddress()
}, [fetchItems, fetchInboxAddress])
// Handle OAuth callback redirect
const gmailParam = searchParams.get('gmail')
const errorParam = searchParams.get('error')
if (gmailParam === 'connected') {
toast({ title: 'Gmail kopplad' })
router.replace('/e/general/invoice-inbox')
} else if (errorParam?.startsWith('gmail_')) {
toast({ title: 'Gmail-koppling misslyckades', variant: 'destructive' })
router.replace('/e/general/invoice-inbox')
}
}, [fetchItems, fetchGmailStatus, searchParams, router, toast])
// Poll while any receipt is mid-match so the UI updates when the LLM returns
useEffect(() => {
const hasInFlight = items.some(
(i) => i.document_type === 'receipt' && i.status === 'ready' && i.match_method === null
)
if (!hasInFlight) return
const interval = setInterval(() => { fetchItems() }, 3000)
return () => clearInterval(interval)
}, [items, fetchItems])
const fetchSuppliers = useCallback(async () => {
try {
@@ -358,21 +459,18 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
}
}, [fetchItems, toast])
const handleScanGmail = useCallback(async () => {
setIsScanning(true)
const handleViewDocument = useCallback(async (documentId: string) => {
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/scan', { method: 'POST' })
if (!res.ok) throw new Error('Scan failed')
const res = await fetch(`/api/documents/${documentId}`)
if (!res.ok) throw new Error('Kunde inte hämta dokument')
const { data } = await res.json()
toast({ title: `Gmail skannad: ${data.scanned} dokument, ${data.classified} klassificerade` })
await fetchItems()
await fetchGmailStatus()
} catch {
toast({ title: 'Gmail-skanning misslyckades', variant: 'destructive' })
} finally {
setIsScanning(false)
if (data?.download_url) {
window.open(data.download_url, '_blank', 'noopener,noreferrer')
}
} catch (err) {
toast({ title: err instanceof Error ? err.message : 'Kunde inte öppna dokumentet', variant: 'destructive' })
}
}, [fetchItems, fetchGmailStatus, toast])
}, [toast])
const handleReject = useCallback(async (itemId: string) => {
try {
@@ -587,7 +685,6 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
const readyCount = items.filter((i) => i.status === 'ready').length
const confirmedCount = items.filter((i) => i.status === 'confirmed').length
const errorCount = items.filter((i) => i.status === 'error').length
const formTotal = convertForm
? convertForm.items.reduce((sum, item) => {
const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100
@@ -603,70 +700,33 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
return (
<div className="space-y-6">
{/* Gmail connection banner */}
{gmailConnection ? (
{/* Arcim inbox address */}
{inboxAddress && (
<Card>
<CardContent className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-50 dark:bg-green-950">
<Check className="h-4 w-4 text-green-600" />
</div>
<div>
<p className="text-sm font-medium">{gmailConnection.email_address}</p>
<p className="text-xs text-muted-foreground">
{gmailConnection.last_sync_at
? `Senast skannad: ${timeAgo(gmailConnection.last_sync_at)}`
: 'Inte skannad ännu'}
</p>
</div>
</div>
<Button variant="ghost" size="sm" onClick={handleDisconnectGmail}>
Koppla från
</Button>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted shrink-0">
<Mail className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Koppla Gmail</p>
<p className="text-xs text-muted-foreground">Hämta leverantörsfakturor automatiskt från din e-post</p>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">Din fakturainkorg</p>
<p className="text-sm font-medium font-mono truncate">{inboxAddress.address}</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleConnectGmail} disabled={isConnectingGmail}>
{isConnectingGmail ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />}
Koppla Gmail
</Button>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="sm" onClick={handleCopyAddress}>
<Copy className="mr-2 h-3.5 w-3.5" />
Kopiera
</Button>
<Button variant="ghost" size="sm" onClick={handleRotateAddress} disabled={isRotating}>
{isRotating ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="mr-2 h-3.5 w-3.5" />}
Rotera
</Button>
</div>
</CardContent>
</Card>
)}
{/* Summary cards */}
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground">Att granska</p>
<p className="text-2xl font-semibold tabular-nums">{readyCount}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground">Konverterade</p>
<p className="text-2xl font-semibold tabular-nums">{confirmedCount}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground">Fel</p>
<p className="text-2xl font-semibold tabular-nums">{errorCount}</p>
</CardContent>
</Card>
</div>
{/* Action bar */}
<div className="flex items-center gap-3">
<input
@@ -689,17 +749,6 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
{isUploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
Ladda upp
</Button>
{gmailConnection && (
<Button
variant="outline"
size="sm"
onClick={handleScanGmail}
disabled={isScanning}
>
{isScanning ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />}
Skanna Gmail
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -720,119 +769,129 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
</TabsList>
</Tabs>
{/* Items table */}
{/* Items list */}
{items.length === 0 ? (
gmailConnection ? (
<EmptyState
icon={Inbox}
title="Ingen inkorg"
description="Skanna Gmail eller ladda upp en faktura."
actionLabel="Skanna Gmail"
onAction={handleScanGmail}
secondaryActionLabel="Ladda upp"
>
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-2 h-4 w-4" />
Ladda upp
</Button>
</EmptyState>
) : (
<EmptyState
icon={Inbox}
title="Ingen inkorg"
description="Koppla Gmail eller ladda upp en faktura för att komma igång."
actionLabel="Koppla Gmail"
onAction={handleConnectGmail}
>
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-2 h-4 w-4" />
Ladda upp
</Button>
</EmptyState>
)
<EmptyState
icon={Inbox}
title="Ingen inkorg"
description={
inboxAddress
? `Skicka fakturor till ${inboxAddress.address} eller ladda upp manuellt.`
: 'Ladda upp en faktura för att komma igång.'
}
actionLabel="Ladda upp"
onAction={() => fileInputRef.current?.click()}
/>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10"></TableHead>
<TableHead>Dokumenttyp</TableHead>
<TableHead>Leverantör</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead>Konfidensgrad</TableHead>
<TableHead>Status</TableHead>
<TableHead>Mottagen</TableHead>
<TableHead className="text-right">Åtgärder</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const supplierName = extractSupplierName(item)
const amount = extractAmount(item)
const isConvertable = item.status === 'ready' && item.document_type === 'supplier_invoice'
const isDismissable = item.status === 'ready'
<div className="overflow-hidden rounded-lg border bg-card divide-y divide-border/60">
{items.map((item) => {
const supplierName = extractSupplierName(item)
const amount = extractAmount(item)
const currency = extractCurrency(item)
const isReceipt = item.document_type === 'receipt'
const isConvertable = item.status === 'ready' && item.document_type === 'supplier_invoice'
const primaryLabel = supplierName
|| item.email_from?.replace(/<.+?>$/, '').replace(/"/g, '').trim()
|| '—'
return (
<TableRow key={item.id} className={item.document_type === 'unknown' ? 'opacity-60' : ''}>
<TableCell>
{item.source === 'email' ? (
<Mail className="h-4 w-4 text-muted-foreground" />
) : (
<Upload className="h-4 w-4 text-muted-foreground" />
)}
</TableCell>
<TableCell>
<span className="text-sm">{DOC_TYPE_LABELS[item.document_type] || item.document_type}</span>
</TableCell>
<TableCell>
<span className="text-sm">{supplierName || '—'}</span>
{item.email_from && !supplierName && (
<span className="block text-xs text-muted-foreground truncate max-w-[200px]">{item.email_from}</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{amount != null ? formatCurrency(amount, extractCurrency(item)) : '—'}
</TableCell>
<TableCell>{confidenceBadge(item.confidence)}</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANTS[item.status] || 'outline'}>
{STATUS_LABELS[item.status] || item.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
return (
<div
key={item.id}
className={cn(
'flex gap-4 px-4 py-3.5 transition-colors',
item.document_type === 'unknown' && 'opacity-60',
item.status === 'rejected' && 'opacity-50'
)}
>
{/* Document icon */}
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted/60 shrink-0 mt-0.5">
{isReceipt ? (
<ReceiptIcon className="h-4 w-4 text-muted-foreground" />
) : (
<FileText className="h-4 w-4 text-muted-foreground" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header row */}
<div className="flex items-baseline justify-between gap-3 flex-wrap">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-sm font-medium">
{DOC_TYPE_LABELS[item.document_type] || item.document_type}
</span>
<span className="text-muted-foreground">·</span>
<span className="text-sm truncate min-w-0">{primaryLabel}</span>
{item.source === 'email' && (
<Mail className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Från e-post" />
)}
</div>
<div className="flex items-baseline gap-2 shrink-0">
{amount != null && (
<span className="text-sm font-medium tabular-nums">
{formatCurrency(amount, currency)}
</span>
)}
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{timeAgo(item.created_at)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{isConvertable && (
<Button
variant="ghost"
size="sm"
onClick={() => openConvertDialog(item)}
>
<Eye className="mr-1 h-3.5 w-3.5" />
Granska
</Button>
)}
{isDismissable && (
<Button
variant="ghost"
size="sm"
onClick={() => handleReject(item.id)}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
</span>
</div>
</div>
{/* Status chip line — only non-default states */}
{(item.status === 'confirmed' || item.status === 'rejected' || item.status === 'error') && (
<div className="mt-1.5">
<Badge variant={STATUS_VARIANTS[item.status] || 'outline'}>
{STATUS_LABELS[item.status] || item.status}
</Badge>
{item.error_message && (
<span className="ml-2 text-xs text-muted-foreground">{item.error_message}</span>
)}
</div>
)}
{/* Match block (receipts only) */}
<MatchBlock item={item} />
{/* Actions */}
{item.status === 'ready' && (
<div className="mt-3 flex items-center justify-end gap-1">
{isConvertable ? (
<Button
variant="outline"
size="sm"
onClick={() => openConvertDialog(item)}
>
Granska
</Button>
) : (
item.document_id && (
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDocument(item.document_id!)}
>
<Eye className="mr-1.5 h-3.5 w-3.5" />
Visa kvitto
</Button>
)
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleReject(item.id)}
aria-label="Avvisa"
title="Avvisa"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
</div>
)
})}
</div>
)}
{/* Convert dialog */}