'use client' import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' import { Inbox, Upload, Mail, FileText, Copy, RotateCcw, Trash2, Check, Loader2, AlertTriangle, ArrowRight, Plus, Link2, Search, Circle, X, } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' 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 matched_transaction_id: string | null created_supplier_invoice_id: string | null error_message: string | null // Set client-side only while a manual upload is in flight. Replaced by a // real server-side row once the AI extraction completes. isPlaceholder?: boolean fileName?: string } 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 router = useRouter() const fileInputRef = useRef(null) const [items, setItems] = useState([]) const [isLoading, setIsLoading] = useState(true) const [selectedId, setSelectedId] = useState(null) // Phone-only master-detail toggle. On screens ('list') // List filter + search (client-side over the already-fetched items list). const [filter, setFilter] = useState<'all' | 'needs_action' | 'done' | 'error'>('all') const [searchTerm, setSearchTerm] = useState('') // Bulk selection. Items linked to a supplier invoice are skipped at delete // time (server returns 409); we still allow them to be selected so the // user can see the "X skipped" toast and learn the rule. const [selectedIds, setSelectedIds] = useState>(new Set()) const [isBulkDeleting, setIsBulkDeleting] = useState(false) // Onboarding card visibility. Hides when all three steps are complete or // the user dismissed it. Persisted to localStorage so refresh doesn't // revive a dismissed card. const [onboardingDismissed, setOnboardingDismissed] = useState(false) // Multi-file upload progress. Null when no queue is running. Reflects the // sequential progress through a batch ({ total, done }) so the button can // show "Laddar X av N…". const [uploadQueue, setUploadQueue] = useState<{ total: number; done: number } | null>(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]) // Read the onboarding-dismissed flag from localStorage after mount // (SSR-safe — no window access during initial render). useEffect(() => { if (typeof window === 'undefined') return try { setOnboardingDismissed( window.localStorage.getItem('gnubok.inbox.onboarding.dismissed') === '1' ) } catch { // private browsing — keep default (show card) } }, []) const handleDismissOnboarding = useCallback(() => { try { window.localStorage.setItem('gnubok.inbox.onboarding.dismissed', '1') } catch { // ignore; in-memory state is enough for this session } setOnboardingDismissed(true) }, []) // Onboarding card visibility — derived from real progress so a user who // already has a working inbox flow never sees the guide. Once they finish // all three steps, the card auto-hides on next render. const hasInboxAddress = !!inboxAddress const hasAnyItem = items.length > 0 const hasResolvedItem = items.some( (it) => !!it.created_supplier_invoice_id || !!it.matched_transaction_id ) const showOnboarding = !onboardingDismissed && !(hasInboxAddress && hasAnyItem && hasResolvedItem) // ── List filter + search (client-side over the fetched list) ─ const filteredItems = useMemo(() => { const term = searchTerm.trim().toLowerCase() return items.filter((item) => { // Status filter const isErr = item.status === 'error' const isDone = !!item.created_supplier_invoice_id || !!item.matched_transaction_id const needsAction = !isErr && !isDone if (filter === 'error' && !isErr) return false if (filter === 'done' && !isDone) return false if (filter === 'needs_action' && !needsAction) return false // Search filter — supplier name, email subject/from, placeholder filename if (term === '') return true const haystack = [ item.extracted_data?.supplier?.name, item.email_subject, item.email_from, item.fileName, ] .filter((v): v is string => !!v) .join(' ') .toLowerCase() return haystack.includes(term) }) }, [items, filter, searchTerm]) // ── Selection ────────────────────────────────────────────── const handleSelect = useCallback(async (id: string) => { setSelectedId(id) setSelected(null) setDocUrl(null) setDocMime(null) setMobileView('detail') 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 ───────────────────────────────────────────────── // `autoSelect`: jump the detail pane to the new placeholder/row. Useful // for a one-off drop (user expects to see what just landed). Harmful in // a multi-file queue (selection yanks around as each file processes). const uploadFile = useCallback(async ( file: File, options: { autoSelect: boolean } = { autoSelect: true }, ) => { // Optimistic placeholder — gives the user an immediate visual response // for the 3–8s while extraction runs. Removed once the real row arrives. const tempId = `temp-${crypto.randomUUID()}` const placeholder: InboxItem = { id: tempId, status: 'received', source: 'upload', created_at: new Date().toISOString(), email_from: null, email_subject: null, email_received_at: null, document_id: null, extracted_data: null, matched_supplier_id: null, matched_transaction_id: null, created_supplier_invoice_id: null, error_message: null, isPlaceholder: true, fileName: file.name, } setItems((prev) => [placeholder, ...prev]) if (options.autoSelect) { setSelectedId(tempId) setSelected(placeholder) } 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 }) setItems((prev) => prev.filter((it) => it.id !== tempId)) await fetchItems() if (options.autoSelect && json.data?.inbox_item_id) { await handleSelect(json.data.inbox_item_id) } } catch (err) { setItems((prev) => prev.filter((it) => it.id !== tempId)) if (options.autoSelect) { setSelectedId((prev) => (prev === tempId ? null : prev)) setSelected((prev) => (prev?.id === tempId ? null : prev)) } toast({ title: 'Uppladdning misslyckades', description: err instanceof Error ? err.message : 'Försök igen.', variant: 'destructive', }) } finally { setIsUploading(false) } }, [fetchItems, handleSelect, toast]) // Sequential queue — running multiple extractions concurrently would // hammer pdfjs on slow boxes. Per-file placeholder rows + the queue // counter on the upload button surface progress. const uploadFiles = useCallback(async (files: File[]) => { if (files.length === 0) return if (files.length === 1) { // Single-file drop: keep the historic behavior of jumping the detail // pane to the new item. Skip the queue counter — it would just flash. await uploadFile(files[0], { autoSelect: true }) return } setUploadQueue({ total: files.length, done: 0 }) try { for (const file of files) { await uploadFile(file, { autoSelect: false }) setUploadQueue((q) => (q ? { ...q, done: q.done + 1 } : null)) } } finally { setUploadQueue(null) } }, [uploadFile]) const handleFileInputChange = useCallback(async (e: React.ChangeEvent) => { const files = Array.from(e.target.files ?? []) if (files.length > 0) await uploadFiles(files) if (fileInputRef.current) fileInputRef.current.value = '' }, [uploadFiles]) const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault() setIsDragging(false) const files = Array.from(e.dataTransfer.files ?? []) if (files.length > 0) await uploadFiles(files) }, [uploadFiles]) // ── 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]) const toggleSelected = useCallback((id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) }, []) const clearSelection = useCallback(() => setSelectedIds(new Set()), []) const handleBulkDelete = useCallback(async () => { if (selectedIds.size === 0) return if (!confirm(`Ta bort ${selectedIds.size} poster ur inkorgen?`)) return // Skip items that the server would 409 on, surface the count to the user. const targets = items.filter((it) => selectedIds.has(it.id)) const deletable = targets.filter((it) => !it.created_supplier_invoice_id) const skipped = targets.length - deletable.length setIsBulkDeleting(true) try { const results = await Promise.allSettled( deletable.map((it) => fetch(`/api/extensions/ext/invoice-inbox/items/${it.id}`, { method: 'DELETE' }) .then(async (res) => { if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'fail') }) ) ) const failed = results.filter((r) => r.status === 'rejected').length const succeeded = deletable.length - failed const parts: string[] = [] if (succeeded > 0) parts.push(`${succeeded} borttagna`) if (skipped > 0) parts.push(`${skipped} kopplade till leverantörsfaktura — hoppade över`) if (failed > 0) parts.push(`${failed} misslyckades`) toast({ title: 'Bulkborttagning klar', description: parts.join(' · '), variant: failed > 0 ? 'destructive' : 'default', }) clearSelection() // If the currently-selected item was deleted, clear the rail. if (selectedId && deletable.some((it) => it.id === selectedId)) { setSelectedId(null) setSelected(null) } await fetchItems() } finally { setIsBulkDeleting(false) } }, [selectedIds, items, selectedId, fetchItems, toast, clearSelection]) // ── 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. On phone ( {/* List */} {/* Document preview (hero) */}
{/* Phone-only back-to-list button */} {selected && ( )} {selected ? ( ) : showOnboarding ? (
fileInputRef.current?.click()} onDismiss={handleDismissOnboarding} isActivating={isRotating} />
) : ( fileInputRef.current?.click()} onActivateInbox={inboxAddress ? null : handleRotateAddress} isActivating={isRotating} /> )} {isDragging && (

Släpp filen för att ladda upp

)}
{/* Fields rail. On phone, sits below the preview (same screen as detail); on md+ it's the third pane. */}
{selected && ( { setAttachOpen(false) await fetchItems() toast({ title: 'Bilaga kopplad till transaktion', description: 'Bokför direkt, eller fortsätt med inkorgen och bokför senare.', action: ( router.push(`/transactions?highlight=${transactionId}`)} > Bokför nu ), }) }} /> )} ) } // ── 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: (transactionId: string) => void | Promise }) { const { toast } = useToast() const [transactions, setTransactions] = useState([]) const [isLoading, setIsLoading] = useState(false) const [attachingId, setAttachingId] = useState(null) const [isCreating, setIsCreating] = useState(false) const [searchTerm, setSearchTerm] = useState('') const targetAmount = pickAmount(item) const targetCurrency = pickCurrency(item) // Prefill for "create transaction from this document" — defaults to a // negative amount because the typical inbox item is an expense receipt // (money out). The user can flip the sign in the form if it's an income // document. const defaultDate = item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10) const defaultAmount = targetAmount != null ? -Math.abs(targetAmount) : 0 const defaultDescription = [ pickSupplierName(item), item.extracted_data?.invoice?.invoiceNumber, ] .filter(Boolean) .join(' · ') || 'Manuell transaktion' const [formDate, setFormDate] = useState(defaultDate) const [formAmount, setFormAmount] = useState( defaultAmount !== 0 ? String(defaultAmount) : '' ) const [formDescription, setFormDescription] = useState(defaultDescription) useEffect(() => { setFormDate(defaultDate) setFormAmount(defaultAmount !== 0 ? String(defaultAmount) : '') setFormDescription(defaultDescription) setSearchTerm('') // Reset whenever a different inbox item opens the dialog. // eslint-disable-next-line react-hooks/exhaustive-deps }, [item.id]) const filteredTransactions = useMemo(() => { const term = searchTerm.trim().toLowerCase() if (term === '') return transactions return transactions.filter((t) => (t.description || '').toLowerCase().includes(term)) }, [transactions, searchTerm]) 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(tx.id) } finally { setAttachingId(null) } } const handleCreateTransaction = async () => { const amountNum = Number(formAmount) if (!Number.isFinite(amountNum) || amountNum === 0) { toast({ title: 'Ange ett belopp skilt från noll', variant: 'destructive' }) return } if (!formDate || !formDescription.trim()) { toast({ title: 'Fyll i datum och beskrivning', variant: 'destructive' }) return } setIsCreating(true) try { const res = await fetch('/api/transactions/create-from-document', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inbox_item_id: item.id, amount: amountNum, transaction_date: formDate, description: formDescription.trim(), }), }) const json = await res.json().catch(() => ({})) if (!res.ok) { toast({ title: json.error || 'Kunde inte skapa transaktion', variant: 'destructive', }) return } const newTxId = json?.data?.transaction_id as string | undefined if (newTxId) { await onAttached(newTxId) } else { // No id back — fall back to closing without the "Bokför nu" CTA. toast({ title: 'Transaktion skapad', description: 'Hittas under Transaktioner för kategorisering.', }) } } finally { setIsCreating(false) } } 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 && transactions.length > 0 && (
setSearchTerm(e.target.value)} className="pl-10" />
)}
{isLoading ? (
Laddar transaktioner…
) : transactions.length === 0 ? (

Hittade ingen transaktion

Skapa en manuell transaktion från underlaget och kategorisera den i transaktionsvyn efter att den är skapad.

setFormDate(e.target.value)} disabled={isCreating} />
setFormAmount(e.target.value)} placeholder="-0.00" className="tabular-nums" disabled={isCreating} />
setFormDescription(e.target.value)} disabled={isCreating} />

Negativt belopp för utgift, positivt för inkomst.

) : filteredTransactions.length === 0 ? (

Inga transaktioner matchar “{searchTerm}”.

) : (
    {filteredTransactions.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, isChecked, onToggleChecked, anyChecked, }: { item: InboxItem selected: boolean onClick: () => void isChecked: boolean onToggleChecked: () => void /** True when bulk-select mode is active anywhere in the list — keeps the checkbox visible (otherwise it's hover-only on desktop). */ anyChecked: boolean }) { const amount = pickAmount(item) const supplierName = pickSupplierName(item) const isErrored = item.status === 'error' const isProcessed = !!item.created_supplier_invoice_id const isLinkedToTransaction = !isProcessed && !!item.matched_transaction_id const isPlaceholder = !!item.isPlaceholder return (
  • {!isPlaceholder && (
    e.stopPropagation()} >
    )}
  • ) } // ── Document preview pane ──────────────────────────────────── // (placed below the row so editors can fold the row cleanly) function DocumentPreview({ docUrl, docMime, isProcessing = false, }: { docUrl: string | null docMime: string | null isProcessing?: boolean }) { if (isProcessing) { return (
    Tolkar dokument med AI…
    ) } 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.