'use client' import { useState, useCallback, useEffect, useRef } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' import { Inbox, Upload, Mail, FileText, Copy, RotateCcw, Trash2, Check, Loader2, AlertTriangle, ArrowRight, Plus, Link2, } from 'lucide-react' import Link from 'next/link' import { cn, formatCurrency } from '@/lib/utils' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { InvoiceExtractionResult } from '@/types' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog' // ── Types ──────────────────────────────────────────────────── interface InboxItem { id: string status: 'received' | 'error' source: 'email' | 'upload' created_at: string email_from: string | null email_subject: string | null email_received_at: string | null document_id: string | null extracted_data: InvoiceExtractionResult | null matched_supplier_id: string | null created_supplier_invoice_id: string | null error_message: string | null } interface InboxAddress { address: string local_part: string status: string } // ── Helpers ────────────────────────────────────────────────── function timeAgo(iso: string): string { const ms = Date.now() - new Date(iso).getTime() const min = Math.floor(ms / 60000) if (min < 1) return 'nyss' if (min < 60) return `${min} min sedan` const h = Math.floor(min / 60) if (h < 24) return `${h} h sedan` const d = Math.floor(h / 24) if (d < 30) return `${d} d sedan` return new Date(iso).toLocaleDateString('sv-SE') } function pickAmount(item: InboxItem): number | null { return item.extracted_data?.totals?.total ?? null } function pickCurrency(item: InboxItem): string { return item.extracted_data?.invoice?.currency ?? 'SEK' } function pickSupplierName(item: InboxItem): string | null { return item.extracted_data?.supplier?.name ?? null } // ── Skeleton ───────────────────────────────────────────────── function WorkspaceSkeleton() { return (
) } // ── Main component ─────────────────────────────────────────── export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const { toast } = useToast() const fileInputRef = useRef(null) const [items, setItems] = useState([]) const [isLoading, setIsLoading] = useState(true) const [selectedId, setSelectedId] = useState(null) const [selected, setSelected] = useState(null) const [docUrl, setDocUrl] = useState(null) const [docMime, setDocMime] = useState(null) const [inboxAddress, setInboxAddress] = useState(null) const [isUploading, setIsUploading] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [isRotating, setIsRotating] = useState(false) const [isDragging, setIsDragging] = useState(false) const [attachOpen, setAttachOpen] = useState(false) // ── Data loading ─────────────────────────────────────────── const fetchItems = useCallback(async () => { try { const res = await fetch('/api/extensions/ext/invoice-inbox/items?limit=50') const json = await res.json() if (res.ok) setItems(json.data?.items ?? []) } catch (err) { console.error('[invoice-inbox] fetchItems failed:', err) } finally { setIsLoading(false) } }, []) const fetchInboxAddress = useCallback(async () => { try { const res = await fetch('/api/extensions/ext/invoice-inbox/inbox/address') if (res.ok) { const { data } = await res.json() setInboxAddress(data) } } catch { // 404 / 503 are expected when no address provisioned yet } }, []) useEffect(() => { fetchItems() fetchInboxAddress() }, [fetchItems, fetchInboxAddress]) // ── Selection ────────────────────────────────────────────── const handleSelect = useCallback(async (id: string) => { setSelectedId(id) setSelected(null) setDocUrl(null) setDocMime(null) try { const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${id}`) const json = await res.json() if (!res.ok) throw new Error(json.error ?? 'Kunde inte hämta posten') const item = json.data as InboxItem setSelected(item) if (item.document_id) { try { const docRes = await fetch(`/api/documents/${item.document_id}`) if (docRes.ok) { const { data } = await docRes.json() setDocUrl(data.download_url ?? null) setDocMime(data.mime_type ?? null) } } catch { // Preview is optional } } } catch (err) { toast({ title: 'Kunde inte ladda dokumentet', description: err instanceof Error ? err.message : 'Försök igen.', variant: 'destructive', }) } }, [toast]) // ── Upload ───────────────────────────────────────────────── const uploadFile = useCallback(async (file: File) => { setIsUploading(true) try { const fd = new FormData() fd.append('file', file) const res = await fetch('/api/extensions/ext/invoice-inbox/upload', { method: 'POST', body: fd, }) const json = await res.json() if (!res.ok) throw new Error(json.error ?? 'Uppladdning misslyckades') toast({ title: 'Dokument uppladdat', description: file.name }) await fetchItems() if (json.data?.inbox_item_id) { await handleSelect(json.data.inbox_item_id) } } catch (err) { toast({ title: 'Uppladdning misslyckades', description: err instanceof Error ? err.message : 'Försök igen.', variant: 'destructive', }) } finally { setIsUploading(false) } }, [fetchItems, handleSelect, toast]) const handleFileInputChange = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) await uploadFile(file) if (fileInputRef.current) fileInputRef.current.value = '' }, [uploadFile]) const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault() setIsDragging(false) const file = e.dataTransfer.files?.[0] if (file) await uploadFile(file) }, [uploadFile]) // ── Delete ───────────────────────────────────────────────── const handleDelete = useCallback(async (id: string) => { if (!confirm('Ta bort dokumentet ur inkorgen?')) return setIsDeleting(true) try { const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${id}`, { method: 'DELETE', }) const json = await res.json() if (!res.ok) throw new Error(json.error ?? 'Kunde inte ta bort') toast({ title: 'Borttagen' }) if (selectedId === id) { setSelectedId(null) setSelected(null) } await fetchItems() } catch (err) { toast({ title: 'Kunde inte ta bort', description: err instanceof Error ? err.message : 'Försök igen.', variant: 'destructive', }) } finally { setIsDeleting(false) } }, [fetchItems, selectedId, toast]) // ── Inbox address ────────────────────────────────────────── const handleCopyAddress = useCallback(() => { if (!inboxAddress) return navigator.clipboard.writeText(inboxAddress.address).catch(() => {}) toast({ title: 'Adress kopierad' }) }, [inboxAddress, toast]) const handleRotateAddress = useCallback(async () => { if (inboxAddress && !confirm('Skapa en ny inkorgsadress? Den gamla slutar att fungera.')) return setIsRotating(true) try { const res = await fetch('/api/extensions/ext/invoice-inbox/inbox/rotate', { method: 'POST', }) const json = await res.json() if (!res.ok) throw new Error(json.error ?? 'Rotation misslyckades') setInboxAddress(json.data) toast({ title: 'Ny adress skapad', description: json.data.address }) } catch (err) { toast({ title: 'Rotation misslyckades', description: err instanceof Error ? err.message : 'Försök igen.', variant: 'destructive', }) } finally { setIsRotating(false) } }, [toast, inboxAddress]) // ── Render ───────────────────────────────────────────────── if (isLoading) return return (
{ e.preventDefault(); if (!isDragging) setIsDragging(true) }} onDragLeave={(e) => { // only clear when leaving the workspace itself, not children if (e.currentTarget === e.target) setIsDragging(false) }} onDrop={handleDrop} >
{/* Top bar */}

Dokumentinkorg

{inboxAddress ? ( <> · {inboxAddress.address} ) : ( )}
{/* Three-pane body */}
{/* List */} {/* Document preview (hero) */}
{selected ? ( ) : ( fileInputRef.current?.click()} onActivateInbox={inboxAddress ? null : handleRotateAddress} isActivating={isRotating} /> )} {isDragging && (

Släpp filen för att ladda upp

)}
{/* Fields rail */}
{selected && ( { setAttachOpen(false) await fetchItems() toast({ title: 'Bilaga kopplad till transaktion' }) }} /> )}
) } // ── Attach-to-transaction dialog ───────────────────────────── interface PickerTransaction { id: string date: string description: string amount: number currency: string } function AttachToTransactionDialog({ open, onOpenChange, item, onAttached, }: { open: boolean onOpenChange: (v: boolean) => void item: InboxItem onAttached: () => void | Promise }) { const { toast } = useToast() const [transactions, setTransactions] = useState([]) const [isLoading, setIsLoading] = useState(false) const [attachingId, setAttachingId] = useState(null) const targetAmount = pickAmount(item) const targetCurrency = pickCurrency(item) useEffect(() => { if (!open) return let cancelled = false setIsLoading(true) ;(async () => { try { const res = await fetch('/api/transactions?unmatched=true') const json = await res.json() if (cancelled) return const rows: PickerTransaction[] = (Array.isArray(json.data) ? json.data : []) .map((t: PickerTransaction) => ({ id: t.id, date: t.date, description: t.description, amount: t.amount, currency: t.currency || 'SEK', })) setTransactions(rankByAmount(rows, targetAmount, targetCurrency)) } catch (err) { console.error('[invoice-inbox/attach] fetch failed:', err) toast({ title: 'Kunde inte ladda transaktioner', variant: 'destructive' }) } finally { if (!cancelled) setIsLoading(false) } })() return () => { cancelled = true } }, [open, targetAmount, targetCurrency, toast]) const handleAttach = async (tx: PickerTransaction) => { if (!item.document_id) return setAttachingId(tx.id) try { const res = await fetch(`/api/transactions/${tx.id}/attach-document`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ document_id: item.document_id }), }) if (!res.ok) { const json = await res.json().catch(() => ({})) toast({ title: json.error || 'Kunde inte koppla bilaga', variant: 'destructive' }) return } await onAttached() } finally { setAttachingId(null) } } return ( Koppla bilaga till transaktion {targetAmount != null ? `Belopp på fakturan: ${formatCurrency(targetAmount, pickCurrency(item))}. Listan är sorterad efter beloppsmatch.` : 'Välj en transaktion att koppla bilagan till.'}
{isLoading ? (
Laddar transaktioner…
) : transactions.length === 0 ? (

Inga okategoriserade transaktioner hittades.

) : (
    {transactions.map((tx) => (
  • ))}
)}
) } function rankByAmount( rows: PickerTransaction[], target: number | null, targetCurrency: string, ): PickerTransaction[] { if (target == null) return rows const t = Math.abs(target) // Same-currency rows rank by amount distance. Cross-currency rows go to // the bottom — comparing a EUR invoice's amount to a SEK transaction's // amount numerically would be misleading and could cause a wrong attachment // (which then becomes verifikation underlag, BFL 5 kap 6 §). The user can // still manually pick a cross-currency match by scrolling down. return [...rows].sort((a, b) => { const aMatch = a.currency === targetCurrency const bMatch = b.currency === targetCurrency if (aMatch !== bMatch) return aMatch ? -1 : 1 if (!aMatch) return 0 const da = Math.abs(Math.abs(a.amount) - t) const db = Math.abs(Math.abs(b.amount) - t) return da - db }) } // ── List row ───────────────────────────────────────────────── function InboxRow({ item, selected, onClick, }: { item: InboxItem selected: boolean onClick: () => void }) { const amount = pickAmount(item) const supplierName = pickSupplierName(item) const isErrored = item.status === 'error' const isProcessed = !!item.created_supplier_invoice_id return (
  • ) } // ── Document preview pane ──────────────────────────────────── function DocumentPreview({ docUrl, docMime, }: { docUrl: string | null docMime: string | null }) { if (!docUrl) { return (
    Inget underlag bifogat
    ) } return (
    {docMime?.startsWith('image/') ? ( // Image: frame hugs the image, capped at the parent's visible box.
    {/* eslint-disable-next-line @next/next/no-img-element */} Underlag
    ) : ( // PDF: iframe needs explicit height — frame fills the available pane.