'use client' import { useRef, useState, useEffect, useMemo } from 'react' import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' import { PageHeader } from '@/components/ui/page-header' import { Upload, Receipt as ReceiptIcon, Loader2, FileText, AlertTriangle, RefreshCw, Pencil, ShieldCheck, ShieldAlert } from 'lucide-react' import { formatCurrency, formatDate } from '@/lib/utils' import ManualExtractDialog from './ManualExtractDialog' import type { ReceiptRowWithPreview } from '@/app/(dashboard)/receipts/page' const STATUS_LABELS: Record = { pending: 'Väntar', processing: 'Bearbetar', ready: 'Klar', confirmed: 'Bokförd', rejected: 'Avvisad', error: 'Fel', } const STATUS_VARIANTS: Record = { pending: 'outline', processing: 'warning', ready: 'secondary', confirmed: 'success', rejected: 'outline', error: 'destructive', } const ALLOWED_MIME = 'application/pdf,image/jpeg,image/png,image/heic,image/heif,image/webp' interface ExtractedReceiptShape { merchant?: { name?: string | null } | null receipt?: { date?: string | null; currency?: string | null } | null totals?: { total?: number | null } | null _verification?: { agrees?: boolean claude_total?: number | null ocr_total?: number | null delta?: number | null ocr_confidence?: number | null } | null _source?: 'ocr_only' | null } // Derive the verification state the UI should render. // - 'agreed' Claude and Textract read the same total → green badge // - 'disagreed' they disagree > 1 öre → yellow warning + numbers // - 'ocr-only' Claude failed but Textract succeeded → neutral // - 'unverified' Textract didn't run (HEIC, large file, no AWS perms) // or agreement data is absent → show nothing type VerificationState = 'agreed' | 'disagreed' | 'ocr-only' | 'unverified' function getVerificationState(row: ReceiptRowWithPreview): VerificationState { const data = row.extracted_data as ExtractedReceiptShape | null if (!data) return 'unverified' if (data._source === 'ocr_only') return 'ocr-only' const v = data._verification if (!v || v.agrees == null) return 'unverified' return v.agrees ? 'agreed' : 'disagreed' } function summarize(row: ReceiptRowWithPreview): { merchant: string; total: number | null; currency: string; date: string | null } { const data = (row.extracted_data as ExtractedReceiptShape | null) ?? {} return { merchant: data.merchant?.name ?? row.document?.file_name ?? 'Okänt kvitto', total: data.totals?.total ?? null, currency: data.receipt?.currency ?? 'SEK', date: data.receipt?.date ?? null, } } // Mirror of the server-side needsRescan heuristic — a row looks stuck when // extraction failed or the total we need to propose a match is missing. // Server is still authoritative; this just gates UI affordances. function rowNeedsRescan(row: ReceiptRowWithPreview): boolean { if (!row.document_id) return false // nothing to rescan without a file if (row.status === 'confirmed') return false if (row.status === 'error') return true const data = row.extracted_data as ExtractedReceiptShape | null if (!data) return true if (data.totals?.total == null) return true return false } // Thumbnail resolves to: image preview | PDF placeholder | missing-source warning. // The last case is legally important (BFL 5 kap 7§) — a receipt without a // source document cannot be booked, so we surface it visibly. function Thumbnail({ row }: { row: ReceiptRowWithPreview }) { const mime = row.document?.mime_type ?? '' const isImage = mime.startsWith('image/') && !mime.includes('heic') && !mime.includes('heif') const isPdf = mime === 'application/pdf' if (!row.document) { return (
Saknar bild
) } if (isImage && row.preview_url) { // eslint-disable-next-line @next/next/no-img-element return ( {row.document.file_name ) } return (
{isPdf ? 'PDF' : 'Fil'}
) } // Optimistic card shown while the upload request is in flight. Replaced by // the persisted row on router.refresh(). Keeps the page from looking empty // during the 5-10 s classify call. function PendingUploadCard({ upload }: { upload: PendingUpload }) { return (
{upload.file_name}
AI läser kvittot… Det här brukar ta 5–10 sekunder.
) } function VerificationBadge({ row }: { row: ReceiptRowWithPreview }) { const state = getVerificationState(row) if (state === 'agreed') { return ( OCR verifierad ) } if (state === 'disagreed') { return ( Behöver granskning ) } if (state === 'ocr-only') { return ( Endast OCR ) } return null } // When Claude and Textract disagree on the total, show the raw numbers so // the user can see which read to trust before accepting downstream. function DisagreementDetail({ row }: { row: ReceiptRowWithPreview }) { const data = row.extracted_data as ExtractedReceiptShape | null const v = data?._verification if (!v || v.agrees !== false) return null const currency = data?.receipt?.currency ?? 'SEK' return (

AI läste {v.claude_total != null ? formatCurrency(v.claude_total, currency) : '—'}, OCR läste{' '} {v.ocr_total != null ? formatCurrency(v.ocr_total, currency) : '—'}. Granska bilden innan du godkänner.

) } // Optimistic placeholder shown in the list while a manual upload is in // flight. The upload handler is synchronous (classify + store + insert // happen before the response returns), so a 5-10 s gap otherwise leaves the // user staring at nothing. We insert a fake row here so the UI shows a real // card immediately and router.refresh() replaces it with the persisted row. interface PendingUpload { key: string file_name: string size_bytes: number mime_type: string } export default function ReceiptsList({ initialItems }: { initialItems: ReceiptRowWithPreview[] }) { const [items, setItems] = useState(initialItems) const [uploading, setUploading] = useState(false) const [pendingUploads, setPendingUploads] = useState([]) const [batchScanning, setBatchScanning] = useState(false) const [rescanId, setRescanId] = useState(null) const [attachingId, setAttachingId] = useState(null) const [manualRow, setManualRow] = useState(null) const fileInputRef = useRef(null) const attachInputRef = useRef(null) const attachTargetRef = useRef(null) const { toast } = useToast() const router = useRouter() useEffect(() => { setItems(initialItems) }, [initialItems]) // Count of rows eligible for rescan — drives the "Skanna oskannade (N)" CTA. const rescanCount = useMemo(() => items.filter(rowNeedsRescan).length, [items]) const handlePickFile = () => fileInputRef.current?.click() const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return e.target.value = '' const pending: PendingUpload = { key: `pending-${Date.now()}-${file.name}`, file_name: file.name, size_bytes: file.size, mime_type: file.type, } setPendingUploads((p) => [pending, ...p]) setUploading(true) try { const form = new FormData() form.append('file', file) const res = await fetch('/api/extensions/ext/invoice-inbox/upload', { method: 'POST', body: form, }) const body = await res.json() if (!res.ok) { toast({ title: 'Uppladdning misslyckades', description: body.error, variant: 'destructive' }) return } toast({ title: 'Kvitto sparat' }) router.refresh() } catch (err) { toast({ title: 'Fel', description: err instanceof Error ? err.message : String(err), variant: 'destructive', }) } finally { // Drop the placeholder on both success and failure. On success the // real row arrives via router.refresh(); on failure the user gets a // toast and an empty list state instead of a stuck "AI läser..." card. setPendingUploads((p) => p.filter((x) => x.key !== pending.key)) setUploading(false) } } const handleRescanOne = async (row: ReceiptRowWithPreview) => { setRescanId(row.id) try { const res = await fetch('/api/extensions/ext/invoice-inbox/rescan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inbox_item_ids: [row.id] }), }) const body = await res.json() if (!res.ok) { toast({ title: 'Skanning misslyckades', description: body.error, variant: 'destructive' }) return } const outcome = body.data.outcomes?.[0] if (outcome?.ok) { toast({ title: 'Skanning klar' }) } else { toast({ title: 'Skanning misslyckades', description: outcome?.error, variant: 'destructive' }) } router.refresh() } finally { setRescanId(null) } } const handlePickAttachFile = (rowId: string) => { attachTargetRef.current = rowId attachInputRef.current?.click() } const handleAttachFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] const rowId = attachTargetRef.current e.target.value = '' attachTargetRef.current = null if (!file || !rowId) return setAttachingId(rowId) try { const form = new FormData() form.append('file', file) const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${rowId}/attach-document`, { method: 'POST', body: form, }) const body = await res.json() if (!res.ok) { toast({ title: 'Kunde inte koppla bild', description: body.error, variant: 'destructive' }) return } toast({ title: 'Bild kopplad', description: body.data.classified ? 'Bearbetar siffrorna…' : 'Kunde inte läsa siffror — skanna igen eller skriv in själv.', }) router.refresh() } finally { setAttachingId(null) } } const handleBatchRescan = async () => { if (rescanCount === 0) return setBatchScanning(true) try { const res = await fetch('/api/extensions/ext/invoice-inbox/rescan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const body = await res.json() if (!res.ok) { toast({ title: 'Batch-skanning misslyckades', description: body.error, variant: 'destructive' }) return } const { rescanned, failed } = body.data toast({ title: `${rescanned} skannade${failed > 0 ? `, ${failed} misslyckades` : ''}`, }) router.refresh() } finally { setBatchScanning(false) } } return (
{rescanCount > 0 && ( )}
} /> {items.length === 0 && pendingUploads.length === 0 ? (

Inga kvitton än

Ladda upp ett kvitto (PDF, JPG, PNG, HEIC eller WebP) så tar AI hand om resten.

) : (
{pendingUploads.map((upload) => ( ))} {items.map((row) => { const s = summarize(row) const statusKey = row.status ?? 'pending' const canRescan = rowNeedsRescan(row) const isRescanning = rescanId === row.id const needsImage = !row.document_id && row.status !== 'confirmed' const isAttaching = attachingId === row.id return (
{s.merchant} {s.total != null && ( {formatCurrency(s.total, s.currency)} )}
{(statusKey === 'processing' || statusKey === 'pending') && ( )} {STATUS_LABELS[statusKey] ?? statusKey} {s.date && ( {formatDate(s.date)} )} {row.document?.file_name && ( · {row.document.file_name} )} {row.source === 'email' && ( · via e-post )}
{row.error_message && (

{row.error_message}

)} {needsImage && (
)} {!needsImage && canRescan && (
)}
) })}
)} {manualRow && ( setManualRow(null)} onSaved={() => { setManualRow(null) router.refresh() }} /> )} ) }