'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, } 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' // ── 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) // ── 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 */}
) } // ── 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.