'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 { 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 { cn, formatCurrency } from '@/lib/utils' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { InvoiceExtractionResult } from '@/types' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' type AccountingMethod = 'accrual' | 'cash' // ── 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 created_journal_entry_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 ───────────────────────────────────────────────── // Mirrors the live layout (top bar + 3-pane card) so the transition from // the route-level loading.tsx to data-loaded content has no visible reflow. // Keep in sync with app/(dashboard)/e/[sector]/[slug]/loading.tsx. 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) // 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 [bookDirectOpen, setBookDirectOpen] = useState(false) // Cash method users see "Bokför direkt" as the primary CTA; accrual users // see "Skapa leverantörsfaktura". Defaults to 'accrual' until we've read // the company settings so we don't flicker the CTA order on first paint. const [accountingMethod, setAccountingMethod] = useState('accrual') // ── 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() // Resolve the company's bookkeeping method — drives CTA hierarchy. fetch('/api/settings') .then((r) => (r.ok ? r.json() : null)) .then((body) => { const method = body?.data?.accounting_method if (method === 'cash' || method === 'accrual') { setAccountingMethod(method) } }) .catch(() => { /* keep 'accrual' default */ }) }, [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 || !!it.created_journal_entry_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 || !!item.created_journal_entry_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) // Intentionally no auto-scroll: in the vertical-stack layout (below xl) // scrolling the preview into view pushes the list off-screen, and the // user has no obvious way back to pick another item. The row-highlight // + the preview content update are enough feedback that the tap took. 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, created_journal_entry_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 && !it.created_journal_entry_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-section body. Below xl (iPad portrait/landscape + phone) the sections stack vertically as a single scrollable feed. With the app sidebar eating ~256px, even iPad landscape (1024–1180px viewport) has only ~570px of workspace — too tight for 3 panes. At xl+ they sit side-by-side as three panes. */}
{/* List — flows naturally below xl; bounded with internal scroll at xl+ */} {/* Document preview (hero) */}
{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. Below xl it stacks below the preview as part of the single vertical feed (top border for separation). At xl+ it's the third pane with a left border. */}
{selected && ( { await Promise.all([fetchItems(), handleSelect(selected.id)]) }} /> )}
) } // ── 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.