'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 */}
{/* Three-pane body */}
{/* List */}
{items.length === 0 ? (
Inkorgen är tom.
) : (
{items.map((item) => (
handleSelect(item.id)}
/>
))}
)}
{/* 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) => (
handleAttach(tx)}
disabled={attachingId !== null}
>
{tx.description}
{tx.date}
{formatCurrency(tx.amount, tx.currency)}
{attachingId === tx.id && }
))}
)}
)
}
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 (
{item.source === 'email' ? (
) : (
)}
{supplierName ?? item.email_subject ?? 'Okänt dokument'}
{isErrored && (
)}
{isProcessed && (
)}
{timeAgo(item.email_received_at ?? item.created_at)}
{amount != null && (
{formatCurrency(amount, pickCurrency(item))}
)}
)
}
// ── 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 */}
) : (
// PDF: iframe needs explicit height — frame fills the available pane.
)}
)
}
// ── Empty preview state ──────────────────────────────────────
function EmptyPreview({
onUploadClick,
onActivateInbox,
isActivating,
}: {
onUploadClick: () => void
onActivateInbox: (() => void) | null
isActivating: boolean
}) {
return (
{onActivateInbox ? 'Aktivera din inkorgsadress' : 'Välj ett dokument från listan'}
{onActivateInbox
? 'Ditt bolag får en unik e-postadress som leverantörer kan skicka fakturor till.'
: 'Eller dra och släpp en fil var som helst på sidan för att ladda upp.'}
{onActivateInbox && (
{isActivating ? (
) : (
)}
Aktivera inkorgsadress
)}
Ladda upp en fil
)
}
// ── Fields rail ──────────────────────────────────────────────
function FieldsRail({
item,
onDelete,
onAttach,
isDeleting,
}: {
item: InboxItem
onDelete: () => void
onAttach: () => void
isDeleting: boolean
}) {
const data = item.extracted_data
const isProcessed = !!item.created_supplier_invoice_id
return (
{/* Email metadata */}
{item.source === 'email' && (item.email_from || item.email_subject) && (
{item.email_from && (
Från
{item.email_from}
)}
{item.email_subject && (
Ämne
{item.email_subject}
)}
{item.email_received_at && (
Mottaget
{new Date(item.email_received_at).toLocaleString('sv-SE')}
)}
)}
{item.error_message && (
Fel vid bearbetning
{item.error_message}
)}
{/* Extracted fields */}
Extraherade fält
{data ? (
) : (
Kunde inte läsa text — manuell registrering krävs.
)}
{/* Actions */}
{isProcessed && item.created_supplier_invoice_id ? (
Öppna leverantörsfaktura
) : (
<>
Koppla till transaktion
Skapa leverantörsfaktura
>
)}
{isDeleting ? (
) : (
)}
Ta bort
{isProcessed && (
Bearbetad
)}
)
}
// ── Extracted fields list ────────────────────────────────────
function ExtractedFieldsList({ data }: { data: InvoiceExtractionResult }) {
const fields: Array<{ label: string; value: string | null }> = [
{ label: 'Leverantör', value: data.supplier?.name ?? null },
{ label: 'Org.nr', value: data.supplier?.orgNumber ?? null },
{ label: 'VAT-nr', value: data.supplier?.vatNumber ?? null },
{ label: 'Bankgiro', value: data.supplier?.bankgiro ?? null },
{ label: 'Plusgiro', value: data.supplier?.plusgiro ?? null },
{ label: 'Fakturanr', value: data.invoice?.invoiceNumber ?? null },
{ label: 'OCR/Referens', value: data.invoice?.paymentReference ?? null },
{ label: 'Fakturadatum', value: data.invoice?.invoiceDate ?? null },
{ label: 'Förfallodatum', value: data.invoice?.dueDate ?? null },
{
label: 'Totalt',
value: data.totals?.total != null ? formatCurrency(data.totals.total, data.invoice?.currency ?? 'SEK') : null,
},
{
label: 'Moms',
value: data.totals?.vatAmount != null ? formatCurrency(data.totals.vatAmount, data.invoice?.currency ?? 'SEK') : null,
},
]
return (
{fields.map((f) => (
{f.label}
{f.value ?? '—'}
))}
{data.vatBreakdown && data.vatBreakdown.length > 0 && (
Momsfördelning
{data.vatBreakdown.map((row, i) => (
{row.rate}%
{formatCurrency(row.base, data.invoice?.currency ?? 'SEK')} +{' '}
{formatCurrency(row.amount, data.invoice?.currency ?? 'SEK')}
))}
)}
)
}