'use client' import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { useTranslations } from 'next-intl' 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 { AttnLine } from '@/components/ui/attn-line' import AiFilledIndicator from '@/components/ui/ai-filled-indicator' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { useToast } from '@/components/ui/use-toast' import { Inbox, Upload, Mail, FileText, Copy, RotateCcw, Trash2, Check, Loader2, AlertTriangle, ArrowRight, Plus, Link2, Search, Circle, X, ChevronDown, Sparkles, MessageCircle, } from 'lucide-react' import Link from 'next/link' import { cn, formatCurrency } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout' import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy' import { useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { InboxChannelContext, InvoiceExtractionResult } from '@/types' import { renderChannelParticipant } from '@/lib/documents/channel-context-notes' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog' import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog' // InboxCustomDomainDialog (egen domän) is built but gated off: see // INBOX_CUSTOM_DOMAINS_ENABLED in extensions/general/invoice-inbox/index.ts. import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker' import { useAgentSheet } from '@/components/agent/AgentSheetProvider' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' type AccountingMethod = 'accrual' | 'cash' // ── Types ──────────────────────────────────────────────────── interface InboxItem { id: string status: 'received' | 'error' source: 'email' | 'upload' | 'whatsapp' created_at: string email_from: string | null email_subject: string | null email_received_at: string | null // Plain-text body of the received email. Always captured, but only worth // showing when the mail carried no usable attachment: that is the case where // the body IS the content (a forwarding-confirmation code from Gmail, an // invoice pasted inline, a note from the sender). email_body_text: 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 // True when AI extraction was skipped: either because the upload caller // passed skip_extraction=true (MCP/agent path) or because the server's // page-count gate skipped a PDF above the auto-extract limit (issue #553). // Distinct from status='error' (extraction failed) and from extracted_data // having empty fields (extraction ran but found nothing). extraction_skipped: boolean // Verified human answers from the delivering chat (source='whatsapp'): // photo caption, representation deltagare + syfte, sender note, and the // open-question state. Null/absent for email and upload items. channel_context?: InboxChannelContext | 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 } // How far the underlag behind the selected row got. // // `none` is the only state that may claim "Inget underlag bifogat": it means the // row carries no document_id at all. A failed or hung metadata read is `error`, // never `none`: the document exists (the row points at it), we just could not // load it, and telling the user their underlag is missing invites a duplicate // upload or the conclusion that the receipt is gone (BFL 7 kap 2 § retention). type DocumentLoadState = 'none' | 'loading' | 'ready' | 'error' // A signed-URL lookup is one indexed row plus a storage sign call: seconds at // worst, even on a cold start. 15s leaves several times that headroom while // still ending the spinner instead of leaving it turning forever. const DOCUMENT_FETCH_TIMEOUT_MS = 15_000 // ── 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 } // True when extraction produced at least one usable field. Distinguishes a // deterministically-parsed underlag (fields present: render the editable // list) from an item whose extracted_data is null/empty (AI never ran, or ran // and found nothing). Currency is ignored because emptyExtraction() seeds it // to 'SEK', so it is never a sign that extraction actually happened. function hasAnyExtractedField(data: InvoiceExtractionResult | null): boolean { if (!data) return false const s = data.supplier const inv = data.invoice const t = data.totals return Boolean( s?.name || s?.orgNumber || s?.vatNumber || s?.bankgiro || s?.plusgiro || inv?.invoiceNumber || inv?.invoiceDate || inv?.dueDate || inv?.paymentReference || t?.subtotal != null || t?.vatAmount != null || t?.total != null || (data.lineItems?.length ?? 0) > 0 || (data.vatBreakdown?.length ?? 0) > 0 ) } // Lifecycle stage of an inbox item. Single source of truth shared by the list // filter, the count pills, and the row icons so they never drift apart. // // Precedence mirrors the FieldsRail: a booked item (supplier invoice OR a // direct journal entry) is done and drops out of the active inbox. A // matched-but-unbooked item is "linked": it STAYS in the inbox as its own // category because the bank payment still needs booking (a document attached // to a transaction is not the same as a booked one). An extraction failure is // "error"; everything else needs a first action. type InboxStatus = 'needs_action' | 'linked' | 'booked' | 'error' function deriveInboxStatus(item: InboxItem): InboxStatus { if (item.created_supplier_invoice_id || item.created_journal_entry_id) return 'booked' if (item.matched_transaction_id) return 'linked' if (item.status === 'error') return 'error' return 'needs_action' } // ── 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 t = useTranslations('inbox_workspace') const fileInputRef = useRef(null) const { openAgentSheet, identity } = useAgentSheet() const [items, setItems] = useState([]) const [isLoading, setIsLoading] = useState(true) // The list read failed (non-2xx, unparseable body, or network). Kept apart // from "the list is empty": with no list at all we know nothing about the // inbox and must not render an authoritative "Inkorgen är tom". const [itemsLoadFailed, setItemsLoadFailed] = useState(false) const [selectedId, setSelectedId] = useState(null) // List filter + search (client-side over the already-fetched items list). // Defaults to 'todo': the active inbox (everything not yet booked), so // booked underlag drop out of the default view while attached-but-unbooked // ones stay visible. const [filter, setFilter] = useState<'todo' | 'linked' | 'booked' | 'error' | 'all'>('todo') 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 [docState, setDocState] = useState('none') // Which selection the in-flight document read belongs to. The user can click // another row while it is running, and a late resolution must not paint its // outcome (a URL, or an error) onto the row that is now selected. const docRequestRef = useRef(null) const [inboxAddress, setInboxAddress] = useState(null) // We asked for the inbox address and did not get an answer we can trust // (5xx, network, unparseable). Distinct from a 404, which honestly means no // address is provisioned yet. const [addressLoadFailed, setAddressLoadFailed] = useState(false) 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) // Bulk-book selected underlag (Modell B): the "Bokför valda" selection-bar // action. The dialog filters the selection to bookable items itself. const [bulkBookOpen, setBulkBookOpen] = useState(false) // Match-to-bank-transaction picker (opens when user clicks "Matcha mot // transaktion" on an unmatched inbox item). const [matchPickerOpen, setMatchPickerOpen] = useState(false) // "Skapa leverantörsfaktura" modal for the selected underlag: opens in // place (instead of navigating to a form page) so the user lands right back // here to pick the next document. const [createSupplierInvoiceOpen, setCreateSupplierInvoiceOpen] = 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=500') const json = await res.json() if (!res.ok) { // No list came back, so we cannot say anything about the inbox: the // list column renders the failure instead of "Inkorgen är tom". setItemsLoadFailed(true) return } const serverItems: InboxItem[] = json.data?.items ?? [] // Preserve optimistic upload placeholders that haven't resolved to a // server row yet. A refetch can now fire mid-upload (a realtime event // from an unrelated booking), and a wholesale replace would briefly // drop the in-flight placeholder. Placeholders carry a `temp-` id that // never collides with a real row, and uploadFile() removes its own // placeholder before its fetchItems(), so this never duplicates. setItems((prev) => { const pending = prev.filter((it) => it.isPlaceholder) return pending.length > 0 ? [...pending, ...serverItems] : serverItems }) setItemsLoadFailed(false) } catch (err) { console.error('[invoice-inbox] fetchItems failed:', err) setItemsLoadFailed(true) } 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) setAddressLoadFailed(false) return } // 404 is the honest "no inbox provisioned yet" answer, so the activate // button is the right thing to offer. Anything else (503 without an // inbound domain, 500, an HTML error page) leaves us not knowing whether // an address exists, and offering "Aktivera inkorgsadress" there is not // just wrong copy: handleRotateAddress skips its confirm dialog when // inboxAddress is null, so one click would silently retire a live address // that suppliers and forwarding rules already point at. setInboxAddress(null) setAddressLoadFailed(res.status !== 404) } catch { setAddressLoadFailed(true) } }, []) 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]) // Realtime: refetch when any invoice_inbox_items row changes for this // company. The inbox is routinely resolved "out of band": the in-app agent // sheet commits a staged create_supplier_invoice_from_inbox / book-direct // operation, the /pending page approves one, or another tab books it, and // none of those paths call this component's fetchItems(). Without this, a // booked underlag stayed in "Att göra" until a manual reload (issue #600). // RLS scopes the channel to the user's company, so we never receive other // tenants' events; we refetch the whole list (rather than patch in place) so // the derived status, count pills, and ordering stay authoritative. Mirrors // the /pending page subscription (app/(dashboard)/pending/page.tsx). useEffect(() => { const supabase = createClient() const channel = supabase .channel('invoice_inbox_items:list') .on( 'postgres_changes', { event: '*', schema: 'public', table: 'invoice_inbox_items' }, () => { fetchItems() } ) .subscribe() return () => { void supabase.removeChannel(channel) } }, [fetchItems]) // 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 ) // The list read failed and left us with nothing: we do not know whether the // inbox is empty. A failed refetch that still has rows on screen is not this. const itemsUnknown = itemsLoadFailed && !hasAnyItem // Never coach "ladda upp ditt första underlag" off a list we could not read: // that step may well be done already. const showOnboarding = !onboardingDismissed && !itemsUnknown && !(hasInboxAddress && hasAnyItem && hasResolvedItem) // ── List filter + search (client-side over the fetched list) ─ // Per-status counts for the filter pills. Computed once over the full list. const statusCounts = useMemo(() => { const counts = { todo: 0, linked: 0, booked: 0, error: 0, all: items.length } for (const item of items) { const status = deriveInboxStatus(item) if (status !== 'booked') counts.todo += 1 if (status === 'linked') counts.linked += 1 if (status === 'booked') counts.booked += 1 if (status === 'error') counts.error += 1 } return counts }, [items]) // Pills, in order. The error pill only appears when there's something errored // (or it's the active filter): keeps the happy-path inbox uncluttered. const pills = useMemo(() => { const list: { key: typeof filter; label: string; count: number }[] = [ { key: 'todo', label: 'Att göra', count: statusCounts.todo }, { key: 'linked', label: 'Kopplade', count: statusCounts.linked }, { key: 'booked', label: 'Bokförda', count: statusCounts.booked }, ] if (statusCounts.error > 0 || filter === 'error') { list.push({ key: 'error', label: 'Fel', count: statusCounts.error }) } list.push({ key: 'all', label: 'Alla', count: statusCounts.all }) return list }, [statusCounts, filter]) const filteredItems = useMemo(() => { const term = searchTerm.trim().toLowerCase() return items.filter((item) => { // Status filter. "todo" is the active inbox: everything except booked. const status = deriveInboxStatus(item) if (filter === 'todo' && status === 'booked') return false if (filter === 'linked' && status !== 'linked') return false if (filter === 'booked' && status !== 'booked') return false if (filter === 'error' && status !== 'error') return false // 'all' → no status narrowing // 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 ────────────────────────────────────────────── // Resolve the signed URL for a row's underlag. Every exit sets docState, so // the preview pane always says which of the four situations it is in: no // document, still loading, ready, or "we could not load it". The pane must // never fall back to "Inget underlag bifogat" for a row that has a // document_id, which is what the old silent catch produced. const loadDocument = useCallback(async (itemId: string, documentId: string | null) => { docRequestRef.current = itemId setDocUrl(null) setDocMime(null) if (!documentId) { setDocState('none') return } setDocState('loading') try { const res = await fetchWithTimeout( `/api/documents/${documentId}`, { method: 'GET' }, { timeoutMs: DOCUMENT_FETCH_TIMEOUT_MS, description: `document ${documentId}` }, ) if (docRequestRef.current !== itemId) return if (!res.ok) { setDocState('error') return } const { data } = await res.json() if (docRequestRef.current !== itemId) return const url: string | null = data?.download_url ?? null if (!url) { // The document row exists but no signed URL came back: still a load // failure, not an absent underlag. setDocState('error') return } setDocUrl(url) setDocMime(data?.mime_type ?? null) setDocState('ready') } catch { // Timeout, offline, or an unparseable body. The document itself is // untouched, so the retry in the preview pane is the whole recovery. if (docRequestRef.current !== itemId) return setDocState('error') } }, []) const handleSelect = useCallback(async (id: string) => { setSelectedId(id) setSelected(null) setDocUrl(null) setDocMime(null) setDocState('none') docRequestRef.current = id // 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) await loadDocument(id, item.document_id) } catch (err) { toast({ title: 'Kunde inte ladda dokumentet', description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.', variant: 'destructive', }) } }, [toast, loadDocument]) // ── 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, email_body_text: 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, extraction_skipped: false, 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') if (json.data?.extraction_skipped) { const pages = json.data?.page_count toast({ title: 'Dokument uppladdat', description: pages ? `Stort dokument (${pages} sidor): AI-tolkning skippad. Du kan koppla det till en transaktion eller skapa leverantörsfaktura manuellt.` : 'AI-tolkning skippad. Du kan koppla dokumentet till en transaktion eller skapa leverantörsfaktura manuellt.', }) } else { 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 ? getUserErrorMessage(err) : '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 ? getUserErrorMessage(err) : '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()), []) // The selected rows, and how many of them can actually be bulk-booked // (matched to a transaction and not yet booked). Drives the "Bokför valda" // button enabled-state and feeds the bulk-book dialog. const selectedItems = useMemo( () => items.filter((it) => selectedIds.has(it.id)), [items, selectedIds], ) const bookableSelectedCount = useMemo( () => selectedItems.filter( (it) => it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id, ).length, [selectedItems], ) 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 handleRotateAddress = useCallback(async () => { // Confirm whenever an address may already exist: either we hold one, or the // read failed and we cannot rule one out. Rotating retires the old address, // and suppliers plus forwarding rules already point at it, so the one case // that must never skip this dialog is the one where we are unsure. if ( (inboxAddress || addressLoadFailed) && !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) setAddressLoadFailed(false) toast({ title: 'Ny adress skapad', description: json.data.address }) } catch (err) { toast({ title: 'Rotation misslyckades', description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.', variant: 'destructive', }) } finally { setIsRotating(false) } }, [toast, inboxAddress, addressLoadFailed]) // ── 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 ? ( ) : addressLoadFailed ? ( // We do not know whether an address exists, so we offer a retry // rather than an activate button that would rotate a live address.
{ void fetchInboxAddress() } }} > {t('address_load_failed')}
) : ( )}
{/* 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). When the inbox is empty there is nothing to preview and no row can be selected, so below xl (stacked feed) this pane is hidden: the list's compact onboarding card is the single onboarding surface, avoiding a duplicated card. */}
{selected ? ( { void loadDocument(selected.id, selected.document_id) }} /> ) : showOnboarding ? (
fileInputRef.current?.click()} onDismiss={handleDismissOnboarding} isActivating={isRotating} />
) : ( fileInputRef.current?.click()} // Only offer activation when we know there is nothing to retire: // a failed address read is not a "you have no address yet". onActivateInbox={inboxAddress || addressLoadFailed ? 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. With an empty inbox no row can be selected, so below xl it is hidden to keep the stacked empty state to just the list column. */}
{selected && ( { await Promise.all([fetchItems(), handleSelect(selected.id)]) }} /> )} {selected && ( { // Stay in the inbox (the whole point of the modal): close, then // refresh the list + the selected item so it shows as converted. setCreateSupplierInvoiceOpen(false) await Promise.all([fetchItems(), handleSelect(selected.id)]) }} /> )} { clearSelection() await fetchItems() }} /> {selected && ( setMatchPickerOpen(false)} inboxItemId={selected.id} extractedData={selected.extracted_data} onMatched={async () => { await Promise.all([fetchItems(), handleSelect(selected.id)]) }} /> )}
) } // ── Inbox address bar ──────────────────────────────────────── // The address plus its copy and rotate controls. Owns the copy state so the // header can report an honest outcome: a clipboard write rejects for ordinary // reasons (insecure context, a blocking Permissions-Policy, a document that // lost focus) and the previous handler swallowed that and said "Adress // kopierad" anyway. The user then waits for supplier invoices at an address // they never captured, which is unrecoverable in the sense that matters: no // invoice ever arrives and nothing explains why. // // Treatment mirrors CopyBlock in components/settings/ApiKeysPanel.tsx: icon // swap for the state, one ochre AttnLine on failure, live region always // mounted. No toast on any path, so nothing here can be evicted by (or evict) // another toast under TOAST_LIMIT = 1. function InboxAddressBar({ address, onRotate, isRotating, }: { address: string onRotate: () => void isRotating: boolean }) { const t = useTranslations('inbox_workspace') const [copyState, setCopyState] = useState('idle') async function handleCopy() { // The clipboard write is the first await, so the click's user activation // still holds when it runs. const next = await copyInboxAddress(address) setCopyState(next) if (next === 'copied') setTimeout(() => setCopyState('idle'), 2000) } return (
· {address}
{/* Always mounted so the sentence is announced when it appears, not merely inserted. */}
{copyState === 'failed' && ( {t('copy_address_failed')} )}
) } // ── 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 t = useTranslations('inbox_workspace') const amount = pickAmount(item) const supplierName = pickSupplierName(item) const isPlaceholder = !!item.isPlaceholder const status = deriveInboxStatus(item) const isErrored = status === 'error' const isBooked = status === 'booked' const isLinkedToTransaction = status === 'linked' // A chat question the sender never answered (48h TTL hit): the missing // info should be completed here instead. Quiet hint, not a status: the // item still books normally. Booked items drop the reminder. const hasUnansweredQuestion = !isBooked && item.channel_context?.pending_question?.status === 'moved_to_app' return (
  • {!isPlaceholder && (
    e.stopPropagation()} >
    )}
  • ) } // ── Document preview pane ──────────────────────────────────── // (placed below the row so editors can fold the row cleanly) export function DocumentPreview({ docUrl, docMime, isProcessing = false, loadState, onRetry, }: { docUrl: string | null docMime: string | null isProcessing?: boolean /** Omitted by callers that only ever hold a resolved URL. */ loadState?: DocumentLoadState onRetry?: () => void }) { const t = useTranslations('inbox_workspace') const state: DocumentLoadState = loadState ?? (docUrl ? 'ready' : 'none') if (isProcessing) { return (
    Tolkar dokument med AI…
    ) } if (state === 'loading') { return (
    {t('document_loading')}
    ) } if (state === 'error' || (state === 'ready' && !docUrl)) { // The row points at a stored document: say that it could not be shown, not // that there is nothing attached. return (
    {t('document_load_failed')} {onRetry && ( )}
    ) } 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.