'use client' import { useState, useCallback, useEffect, useRef } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { EmptyState } from '@/components/ui/empty-state' import { useToast } from '@/components/ui/use-toast' import { Inbox, Upload, Mail, FileText, RefreshCw, Check, X, Eye, Loader2, Plus, Trash2, } from 'lucide-react' import Link from 'next/link' import { useSearchParams, useRouter } from 'next/navigation' import { formatCurrency, formatDate } from '@/lib/utils' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { InvoiceExtractionResult } from '@/types' // ── Types ──────────────────────────────────────────────────── interface InboxItem { id: string status: string document_type: string confidence: number | null source: 'email' | 'upload' created_at: string extracted_data: InvoiceExtractionResult | null matched_supplier_id: string | null document_id: string | null email_from: string | null email_subject: string | null error_message: string | null } interface Supplier { id: string name: string org_number: string | null default_expense_account: string | null } interface ConvertForm { supplier_id: string supplier_invoice_number: string invoice_date: string due_date: string currency: string payment_reference: string notes: string items: ConvertFormItem[] } interface ConvertFormItem { description: string amount: number account_number: string vat_rate: number } // ── Constants ──────────────────────────────────────────────── const DOC_TYPE_LABELS: Record = { supplier_invoice: 'Leverantörsfaktura', receipt: 'Kvitto', government_letter: 'Myndighetsbrev', unknown: 'Okänt', } const STATUS_LABELS: Record = { ready: 'Klar', confirmed: 'Bekräftad', rejected: 'Avvisad', error: 'Fel', processing: 'Bearbetar', pending: 'Väntar', } const STATUS_VARIANTS: Record = { ready: 'secondary', confirmed: 'success', rejected: 'outline', error: 'destructive', processing: 'warning', pending: 'outline', } const VAT_OPTIONS = [ { value: '0.25', label: '25%' }, { value: '0.12', label: '12%' }, { value: '0.06', label: '6%' }, { value: '0', label: '0%' }, ] // ── Helpers ────────────────────────────────────────────────── function confidenceBadge(confidence: number | null) { if (confidence === null) return null if (confidence >= 0.9) return Hög if (confidence >= 0.7) return Medium return Låg } function extractSupplierName(item: InboxItem): string | null { if (item.document_type !== 'supplier_invoice' || !item.extracted_data) return null return item.extracted_data.supplier?.name || null } function extractAmount(item: InboxItem): number | null { if (!item.extracted_data) return null return item.extracted_data.totals?.total ?? null } function extractCurrency(item: InboxItem): string { const data = item.extracted_data as Record | null if (!data) return 'SEK' // InvoiceExtractionResult uses invoice.currency, ReceiptExtractionResult uses receipt.currency const invoice = data.invoice as Record | undefined const receipt = data.receipt as Record | undefined return (invoice?.currency as string) || (receipt?.currency as string) || 'SEK' } function timeAgo(isoDate: string): string { const diff = Date.now() - new Date(isoDate).getTime() const minutes = Math.floor(diff / 60000) if (minutes < 1) return 'just nu' if (minutes < 60) return `${minutes} min sedan` const hours = Math.floor(minutes / 60) if (hours < 24) return `${hours} tim sedan` const days = Math.floor(hours / 24) return `${days} dag${days > 1 ? 'ar' : ''} sedan` } function buildInitialForm(item: InboxItem, defaultExpenseAccount?: string): ConvertForm { const data = item.extracted_data const fallbackAccount = defaultExpenseAccount || '5410' let formItems: ConvertFormItem[] if (data?.lineItems?.length) { formItems = data.lineItems.map((li) => ({ description: li.description, amount: li.lineTotal ?? 0, account_number: li.accountSuggestion || fallbackAccount, vat_rate: li.vatRate != null ? li.vatRate / 100 : 0.25, })) // If all line item amounts are 0 but we have a total, distribute evenly const allZero = formItems.every((item) => item.amount === 0) const extractedTotal = data.totals?.subtotal ?? data.totals?.total if (allZero && extractedTotal && extractedTotal > 0) { const perItem = Math.round((extractedTotal / formItems.length) * 100) / 100 formItems.forEach((item) => { item.amount = perItem }) } } else { formItems = [{ description: '', amount: 0, account_number: fallbackAccount, vat_rate: 0.25 }] } return { supplier_id: item.matched_supplier_id || '', supplier_invoice_number: data?.invoice?.invoiceNumber || '', invoice_date: data?.invoice?.invoiceDate || '', due_date: data?.invoice?.dueDate || '', currency: data?.invoice?.currency || 'SEK', payment_reference: data?.invoice?.paymentReference || '', notes: '', items: formItems, } } // ── Skeleton ───────────────────────────────────────────────── function WorkspaceSkeleton() { return (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) } // ── Main Component ─────────────────────────────────────────── export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProps) { const { toast } = useToast() const fileInputRef = useRef(null) const searchParams = useSearchParams() const router = useRouter() const [items, setItems] = useState([]) 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 } | null>(null) const [isConnectingGmail, setIsConnectingGmail] = useState(false) // Convert dialog state const [convertItem, setConvertItem] = useState(null) const [convertForm, setConvertForm] = useState(null) const [suppliers, setSuppliers] = useState([]) const [isConverting, setIsConverting] = useState(false) const [isCreatingSupplier, setIsCreatingSupplier] = useState(false) const [formErrors, setFormErrors] = useState>({}) const [documentUrl, setDocumentUrl] = useState(null) const [documentMimeType, setDocumentMimeType] = useState(null) const [suggestedMatch, setSuggestedMatch] = useState<{ invoiceId: string transaction: { id: string; description: string; amount: number; currency: string; date: string } } | null>(null) const [isConfirmingMatch, setIsConfirmingMatch] = useState(false) // ── Data fetching ──────────────────────────────────────── const fetchItems = useCallback(async () => { setIsLoading(true) try { const params = new URLSearchParams({ limit: '50' }) if (statusFilter !== 'all') params.set('status', statusFilter) const res = await fetch(`/api/extensions/ext/invoice-inbox/items?${params}`) if (!res.ok) throw new Error('Failed to fetch items') const { data } = await res.json() setItems(data?.items || []) } catch { toast({ title: 'Kunde inte hämta inkorgen', variant: 'destructive' }) } finally { setIsLoading(false) } }, [statusFilter, toast]) const fetchGmailStatus = useCallback(async () => { try { const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/status') if (!res.ok) return const { data } = await res.json() const active = data?.connections?.find((c: { status: string }) => c.status === 'active') setGmailConnection(active || null) } catch { /* silent */ } }, []) const handleConnectGmail = useCallback(async () => { setIsConnectingGmail(true) 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 } catch { toast({ title: 'Kunde inte starta Gmail-koppling', variant: 'destructive' }) setIsConnectingGmail(false) } }, [toast]) const handleDisconnectGmail = useCallback(async () => { 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' }) } }, [toast]) useEffect(() => { fetchItems() fetchGmailStatus() // 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]) const fetchSuppliers = useCallback(async () => { try { const res = await fetch('/api/suppliers') if (!res.ok) return const { data } = await res.json() setSuppliers(data || []) } catch { /* silent */ } }, []) // ── Actions ────────────────────────────────────────────── const handleUpload = useCallback(async (file: File) => { setIsUploading(true) try { const form = new FormData() form.append('file', file) const res = await fetch('/api/extensions/ext/invoice-inbox/upload', { method: 'POST', body: form, }) if (!res.ok) { const { error } = await res.json() throw new Error(error || 'Upload failed') } toast({ title: 'Dokument uppladdat och klassificerat' }) await fetchItems() } catch (err) { toast({ title: err instanceof Error ? err.message : 'Uppladdning misslyckades', variant: 'destructive' }) } finally { setIsUploading(false) } }, [fetchItems, toast]) const handleScanGmail = useCallback(async () => { setIsScanning(true) try { const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/scan', { method: 'POST' }) if (!res.ok) throw new Error('Scan failed') 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) } }, [fetchItems, fetchGmailStatus, toast]) const handleReject = useCallback(async (itemId: string) => { try { const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${itemId}/reject`, { method: 'PATCH' }) if (!res.ok) throw new Error('Reject failed') setItems((prev) => prev.map((i) => i.id === itemId ? { ...i, status: 'rejected' } : i)) toast({ title: 'Dokument avvisat' }) } catch { toast({ title: 'Kunde inte avvisa dokumentet', variant: 'destructive' }) } }, [toast]) const openConvertDialog = useCallback(async (item: InboxItem) => { setConvertItem(item) // Find matched supplier's default expense account const matchedSupplier = item.matched_supplier_id ? suppliers.find((s) => s.id === item.matched_supplier_id) : null setConvertForm(buildInitialForm(item, matchedSupplier?.default_expense_account || undefined)) setFormErrors({}) setDocumentUrl(null) setDocumentMimeType(null) fetchSuppliers() // Fetch document preview URL if (item.document_id) { try { const res = await fetch(`/api/documents/${item.document_id}`) if (res.ok) { const { data } = await res.json() setDocumentUrl(data.download_url) setDocumentMimeType(data.mime_type) } } catch { /* silent */ } } }, [fetchSuppliers, suppliers]) // ── Convert form handlers ──────────────────────────────── const updateFormField = useCallback((field: keyof ConvertForm, value: string) => { setConvertForm((prev) => prev ? { ...prev, [field]: value } : prev) setFormErrors((prev) => { const next = { ...prev } delete next[field] return next }) }, []) const handleCreateSupplierFromExtraction = useCallback(async () => { if (!convertItem?.extracted_data?.supplier?.name) return setIsCreatingSupplier(true) try { const extracted = convertItem.extracted_data.supplier const res = await fetch('/api/suppliers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: extracted.name, supplier_type: 'swedish_business', org_number: extracted.orgNumber || undefined, vat_number: extracted.vatNumber || undefined, address_line1: extracted.address || undefined, bankgiro: extracted.bankgiro || undefined, plusgiro: extracted.plusgiro || undefined, }), }) if (!res.ok) { const { error } = await res.json() throw new Error(error || 'Failed to create supplier') } const { data: newSupplier } = await res.json() setSuppliers((prev) => [...prev, newSupplier]) updateFormField('supplier_id', newSupplier.id) toast({ title: `Leverantör "${extracted.name}" skapad` }) } catch (err) { toast({ title: err instanceof Error ? err.message : 'Kunde inte skapa leverantör', variant: 'destructive' }) } finally { setIsCreatingSupplier(false) } }, [convertItem, updateFormField, toast]) const updateLineItem = useCallback((index: number, field: keyof ConvertFormItem, value: string | number) => { setConvertForm((prev) => { if (!prev) return prev const items = [...prev.items] items[index] = { ...items[index], [field]: value } return { ...prev, items } }) setFormErrors((prev) => { const next = { ...prev } delete next[`items.${index}.${field}`] return next }) }, []) const addLineItem = useCallback(() => { setConvertForm((prev) => { if (!prev) return prev return { ...prev, items: [...prev.items, { description: '', amount: 0, account_number: '', vat_rate: 0.25 }] } }) }, []) const removeLineItem = useCallback((index: number) => { setConvertForm((prev) => { if (!prev || prev.items.length <= 1) return prev return { ...prev, items: prev.items.filter((_, i) => i !== index) } }) }, []) const validateForm = useCallback((): boolean => { if (!convertForm) return false const errors: Record = {} if (!convertForm.supplier_id) errors.supplier_id = 'Välj leverantör' if (!convertForm.supplier_invoice_number.trim()) errors.supplier_invoice_number = 'Fakturanummer krävs' if (!convertForm.invoice_date) errors.invoice_date = 'Fakturadatum krävs' if (!convertForm.due_date) errors.due_date = 'Förfallodatum krävs' convertForm.items.forEach((item, i) => { if (!item.description.trim()) errors[`items.${i}.description`] = 'Beskrivning krävs' if (!item.account_number || !/^\d{4}$/.test(item.account_number)) errors[`items.${i}.account_number`] = '4-siffrigt kontonummer' if (item.amount < 0) errors[`items.${i}.amount`] = 'Belopp kan inte vara negativt' }) setFormErrors(errors) return Object.keys(errors).length === 0 }, [convertForm]) const handleConvert = useCallback(async () => { if (!convertItem || !convertForm) return if (!validateForm()) return setIsConverting(true) try { const payload = { supplier_id: convertForm.supplier_id, supplier_invoice_number: convertForm.supplier_invoice_number, invoice_date: convertForm.invoice_date, due_date: convertForm.due_date, currency: convertForm.currency || 'SEK', payment_reference: convertForm.payment_reference || undefined, notes: convertForm.notes || undefined, items: convertForm.items.map((item) => ({ description: item.description, amount: item.amount, account_number: item.account_number, vat_rate: item.vat_rate, })), } const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${convertItem.id}/convert`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!res.ok) { const text = await res.text() console.error('[convert] Server error:', res.status, text) let msg = 'Konvertering misslyckades' try { msg = JSON.parse(text).error || msg } catch { /* use default */ } throw new Error(msg) } const { data: result } = await res.json() setItems((prev) => prev.map((i) => i.id === convertItem.id ? { ...i, status: 'confirmed' } : i)) setConvertItem(null) setConvertForm(null) // If a matching transaction was found, show confirmation prompt if (result.suggested_transaction) { const tx = result.suggested_transaction const txAmount = formatCurrency(Math.abs(tx.amount), tx.currency) setSuggestedMatch({ invoiceId: result.id, transaction: tx }) toast({ title: `Leverantörsfaktura skapad — matchande transaktion hittad (${tx.description}, ${txAmount})` }) } else { toast({ title: 'Leverantörsfaktura skapad' }) } } catch (err) { toast({ title: err instanceof Error ? err.message : 'Konvertering misslyckades', variant: 'destructive' }) } finally { setIsConverting(false) } }, [convertItem, convertForm, validateForm, toast]) const handleConfirmMatch = useCallback(async () => { if (!suggestedMatch) return setIsConfirmingMatch(true) try { const res = await fetch(`/api/transactions/${suggestedMatch.transaction.id}/match-supplier-invoice`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ supplier_invoice_id: suggestedMatch.invoiceId }), }) if (!res.ok) { const { error } = await res.json().catch(() => ({ error: 'Matchning misslyckades' })) throw new Error(error) } toast({ title: 'Transaktion matchad och bokförd' }) setSuggestedMatch(null) } catch (err) { toast({ title: err instanceof Error ? err.message : 'Matchning misslyckades', variant: 'destructive' }) } finally { setIsConfirmingMatch(false) } }, [suggestedMatch, toast]) // ── Computed ───────────────────────────────────────────── 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 return sum + item.amount + vatAmount }, 0) : 0 // ── Render ─────────────────────────────────────────────── if (isLoading && items.length === 0) { return } return (
{/* Gmail connection banner */} {gmailConnection ? (

{gmailConnection.email_address}

{gmailConnection.last_sync_at ? `Senast skannad: ${timeAgo(gmailConnection.last_sync_at)}` : 'Inte skannad ännu'}

) : (

Koppla Gmail

Hämta leverantörsfakturor automatiskt från din e-post

)} {/* Summary cards */}

Att granska

{readyCount}

Konverterade

{confirmedCount}

Fel

{errorCount}

{/* Action bar */}
{ const file = e.target.files?.[0] if (file) handleUpload(file) e.target.value = '' }} /> {gmailConnection && ( )}
{/* Filter tabs */} Alla ({items.length}) Redo ({readyCount}) Bekräftade ({confirmedCount}) Avvisade {/* Items table */} {items.length === 0 ? ( gmailConnection ? ( ) : ( ) ) : ( Dokumenttyp Leverantör Belopp Konfidensgrad Status Mottagen Åtgärder {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' return ( {item.source === 'email' ? ( ) : ( )} {DOC_TYPE_LABELS[item.document_type] || item.document_type} {supplierName || '—'} {item.email_from && !supplierName && ( {item.email_from} )} {amount != null ? formatCurrency(amount, extractCurrency(item)) : '—'} {confidenceBadge(item.confidence)} {STATUS_LABELS[item.status] || item.status} {timeAgo(item.created_at)}
{isConvertable && ( )} {isDismissable && ( )}
) })}
)} {/* Convert dialog */} { if (!open) { setConvertItem(null); setConvertForm(null); setDocumentUrl(null) } }}> Konvertera till leverantörsfaktura {convertForm && convertItem && (
{/* Document preview */} {documentUrl && (
{documentMimeType?.startsWith('image/') ? ( // eslint-disable-next-line @next/next/no-img-element Dokument ) : ( Visa originaldokument )}
)} {/* Extracted supplier context */} {convertItem.extracted_data?.supplier?.name && (
AI extraherade: {convertItem.extracted_data.supplier.name} {convertItem.extracted_data.supplier.orgNumber && ( ({convertItem.extracted_data.supplier.orgNumber}) )}
)} {/* Supplier selector */}
{formErrors.supplier_id &&

{formErrors.supplier_id}

} {!convertForm.supplier_id && convertItem.extracted_data?.supplier?.name ? ( ) : ( Skapa ny leverantör )}
{/* Invoice header fields */}
updateFormField('supplier_invoice_number', e.target.value)} className={formErrors.supplier_invoice_number ? 'border-destructive' : ''} /> {formErrors.supplier_invoice_number &&

{formErrors.supplier_invoice_number}

}
updateFormField('payment_reference', e.target.value)} placeholder="OCR / referens" />
updateFormField('invoice_date', e.target.value)} className={formErrors.invoice_date ? 'border-destructive' : ''} /> {formErrors.invoice_date &&

{formErrors.invoice_date}

}
updateFormField('due_date', e.target.value)} className={formErrors.due_date ? 'border-destructive' : ''} /> {formErrors.due_date &&

{formErrors.due_date}

}
{/* Line items */}
{convertForm.items.map((lineItem, index) => (
{index === 0 && } updateLineItem(index, 'description', e.target.value)} placeholder="Beskrivning" className={formErrors[`items.${index}.description`] ? 'border-destructive' : ''} /> {formErrors[`items.${index}.description`] &&

{formErrors[`items.${index}.description`]}

}
{index === 0 && } updateLineItem(index, 'amount', e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)} placeholder="0.00" className={`tabular-nums ${formErrors[`items.${index}.amount`] ? 'border-destructive' : ''}`} /> {formErrors[`items.${index}.amount`] &&

{formErrors[`items.${index}.amount`]}

}
{index === 0 && } updateLineItem(index, 'account_number', e.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="5410" maxLength={4} className={`font-mono tabular-nums ${formErrors[`items.${index}.account_number`] ? 'border-destructive' : ''}`} /> {formErrors[`items.${index}.account_number`] &&

{formErrors[`items.${index}.account_number`]}

}
{index === 0 && }
{index === 0 && } {convertForm.items.length > 1 && ( )}
))}
{/* Totals */}

Totalt inkl. moms

{formatCurrency(formTotal, convertForm.currency)}

)}
{/* Match confirmation dialog */} { if (!open) setSuggestedMatch(null) }}> Matchande transaktion hittad {suggestedMatch && (
{suggestedMatch.transaction.description} {formatCurrency(Math.abs(suggestedMatch.transaction.amount), suggestedMatch.transaction.currency)}

{suggestedMatch.transaction.date}

Vill du matcha denna transaktion med leverantörsfakturan? En betalningsverifikation bokförs automatiskt.

)}
) }