'use client' import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { useCompanySettings } from '@/lib/reference-data/hooks' 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 { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { Inbox, Upload, Mail, FileText, Copy, RotateCcw, Trash2, Check, Loader2, AlertTriangle, ArrowRight, Plus, Link2, ExternalLink, FileQuestion, Search, ChevronDown, ChevronRight, Sparkles, Maximize2, Globe, } from 'lucide-react' import Link from 'next/link' import { cn, formatCurrency, formatDate, formatDateLong, formatDateTime } from '@/lib/utils' import { QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table' import { useRangeSelect } from '@/lib/hooks/use-range-select' import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks' import { StartCard } from '@/components/dashboard/StartCard' import EditKonteringDialog from '@/components/extensions/general/EditKonteringDialog' import InvoiceInboxSkeleton from '@/components/extensions/general/InvoiceInboxSkeleton' import { WhatsAppMark } from '@/components/extensions/general/WhatsAppMark' import { useReceiptHunt } from '@/components/extensions/general/use-receipt-hunt' 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, useCompanyOptional } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { useBranding } from '@/lib/branding/brand-context' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { AccountingMethod, InboxChannelContext, InvoiceExtractionResult, InboxItemSource } from '@/types' import { renderChannelParticipant } from '@/lib/documents/channel-context-notes' import { selectInboxFields } from '@/lib/documents/inbox-field-visibility' import { matchesInboxKindFilter, resolveInboxKind, INBOX_KIND_FILTERS, type InboxKindFilter, } from '@/lib/documents/inbox-kind' 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, getResponseErrorMessage, } from '@/lib/errors/get-error-message' import { notifySessionExpired } from '@/lib/auth/session-timeout-shared' import { exceedsHostedUploadLimit, exceedsInboxUploadLimit, inboxTooLargeMessage, } from '@/lib/documents/upload-size' import { shrinkImageForUpload } from '@/lib/documents/shrink-image' import { uploadViaSignedUrl } from '@/lib/documents/direct-upload' /** * A failure whose message is already the sentence to show the user, resolved * where the response was still in hand. * * The old `throw new Error(json.error ?? '…')` lost two things. A body that * is not JSON (an HTML error page, an empty 502, a request rejected before it * reached the route) made `res.json()` itself throw, and `error` is an object * on the structured envelope, which stringified to "[object Object]". Both * ended at the generic "Något gick fel. Försök igen." An expired session on a * mobile tab is exactly the second shape, so the one failure the user could * have fixed in a tap was also the one that said the least. */ class ResolvedFailure extends Error { constructor(message: string, readonly status: number) { super(message) } } /** * Read a failed response into a displayable message, and let the session * controller know if the reason was an expired session (it redirects to * /login; the toast below is what the user sees on the way there). */ async function resolveFailure(response: Response): Promise { notifySessionExpired(response) return new ResolvedFailure(await getResponseErrorMessage(response), response.status) } function failureText(err: unknown): string { return err instanceof ResolvedFailure ? err.message : getUserErrorMessage(err) } /** * Leave a server-side trace when an upload fails. * * A request the middleware or the platform answers before the route runs * leaves nothing behind in the function logs, so "uploading from my phone * just fails" was untraceable: the successful uploads were all we could see. * /api/log is the existing rate-limited, PII-redacting client sink, and it is * the one API path exempt from the session-timeout gate, so it still records * the report when an expired session is the very thing being reported. * Metadata only, never the document. */ function reportUploadFailure(report: { status: number size: number type: string reason: string }): void { void fetch('/api/log', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'underlag upload failed', extra: report }), }).catch(() => { // Reporting the failure must never become a second failure. }) } // ── Types ──────────────────────────────────────────────────── // Mirrors the extension's UnderlagStatus: the anchoring verdict, or 'unknown' // when the server could not read the document row. Anything but 'anchored' // keeps the item out of the booked bucket. type UnderlagStatus = 'anchored' | 'unlinked' | 'unlinked_locked' | 'anchored_elsewhere' | 'unknown' interface InboxItem { id: string // 'processing' is the staged-upload in-flight state: the row exists (the // instant receipt ack) but the deferred AI extraction has not landed; // extracted_data is null until the realtime flip to 'received'. status: 'received' | 'processing' | 'error' source: InboxItemSource 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 // Sender-declared kind from the +lev / +ver plus-address tag. A column, so // it survives re-extraction; wins over extracted_data.documentKind for the // row badge and the type filter. Absent on client-side placeholders. kind_hint?: 'supplier_invoice' | 'receipt' | null matched_supplier_id: string | null matched_transaction_id: string | null created_supplier_invoice_id: string | null created_journal_entry_id: string | null // The verifikat that anchors the matched transaction when it is already // booked (directly or via a bulk-book samlingsverifikat). Server-derived by // GET /items: created_journal_entry_id is UNIQUE per verifikat, so on a // samlingsverifikat only one of N items can carry the stamp; this field is // what lets the rest read as booked. Absent on client-side placeholders. matched_transaction_journal_entry_id?: string | null // Whether THIS item's underlag reached that verifikat (#1548). The // transaction being booked is a fact about the transaction, not about the // item's document: one whose link failed ('unlinked', transient: the daily // reconcile retries it), whose verifikat sits in a locked period // ('unlinked_locked', unlock first), that sits on another verifikat // ('anchored_elsewhere', a human decision), or that could not be read // ('unknown') keeps the item in "Att göra". null when nothing was derived // (no booked matched transaction, or the item is stamped). Absent on // client-side placeholders. underlag_status?: UnderlagStatus | 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 } // One received mail per inbox, from the InboundMailReceived history event // the inbound webhook appends (#2181). Sender, subject and address are // deliberately absent from the event (processing_history is outside the // erasure path); the route resolves inbox_id to the company's own address // at read time, and the filed item ids are what the panel links to. interface InboundMailAttachment { id: string outcome: 'filed' | 'duplicate' | 'rejected' | 'failed' inbox_item_id?: string reason?: string mime?: string } interface InboundMail { event_id: string email_id: string occurred_at: string inbox_id: string | null custom_domain: boolean tags: string[] unknown_tag_count: number inbox_local_part: string | null inbox_status: string | null kind_hint: string | null tag_conflict: boolean outcome: string attachment_count: number inbox_item_id: string | null attachments: InboundMailAttachment[] } // Window for the received-mail panel; the route caps at 365. const INBOUND_MAIL_DAYS = 30 // `acme-x7f2@inbox.example` + 'lev' → `acme-x7f2+lev@inbox.example`. The // webhook splits the local part at the first `+` and looks up what is before // it, so the tag never changes which company the mail reaches. function plusAddress(address: string, tag: string): string { const at = address.indexOf('@') if (at === -1) return address return `${address.slice(0, at)}+${tag}${address.slice(at)}` } // 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 { const total = item.extracted_data?.totals?.total if (total != null) return total // Non-invoice documents (bankintyg, avtal) have no total; when exactly one // distinct amount was read off the document, that is the amount to show. // Two or more stay ambiguous and render as no amount. const distinct = [ ...new Set( (item.extracted_data?.prominentAmounts ?? []) .map((a) => a.amount) .filter((a) => Number.isFinite(a) && a !== 0), ), ] return distinct.length === 1 ? distinct[0] : 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 } function pickInvoiceDate(item: InboxItem): string | null { return item.extracted_data?.invoice?.invoiceDate ?? 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 || // Same meaningful-amount predicate as the Belopp row render filter: a // zero-only prominentAmounts list must not count as "found something" // and suppress the retry / upgrade affordances. (data.prominentAmounts ?? []).some((a) => Number.isFinite(a.amount) && a.amount !== 0) ) } /** * The fields the extraction is scored against, in the order a person reads * them. A subset of what hasAnyExtractedField checks (that test also counts * lineItems, vatBreakdown and prominentAmounts), so the "fält ifyllda" counter * never claims a field the "is anything here at all" test does not count: the * reverse can differ, e.g. a bankintyg with only prominentAmounts has fields * but counts 0 here. */ const EXTRACTED_FIELD_ACCESSORS: ((d: InvoiceExtractionResult) => unknown)[] = [ (d) => d.supplier?.name, (d) => d.supplier?.orgNumber, (d) => d.supplier?.vatNumber, (d) => d.supplier?.bankgiro, (d) => d.supplier?.plusgiro, (d) => d.invoice?.invoiceNumber, (d) => d.invoice?.invoiceDate, (d) => d.invoice?.dueDate, (d) => d.invoice?.paymentReference, (d) => d.totals?.subtotal, (d) => d.totals?.vatAmount, (d) => d.totals?.total, ] /** How many of them the extraction actually filled in. */ function countExtractedFields(data: InvoiceExtractionResult | null): number { if (!data) return 0 return EXTRACTED_FIELD_ACCESSORS.reduce( (n, get) => n + (get(data) != null && get(data) !== '' ? 1 : 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, a // direct journal entry, OR a matched transaction that is itself booked) 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' | 'processing' | 'linked' | 'booked' | 'error' // A matched transaction that is booked while this item's own underlag is not // on its verifikat: not "booked" for the inbox, and not bookable either (the // book routes 409 on an already-booked transaction). The rail explains it // instead of offering a bridge that can only fail. function isUnderlagDivergent(item: InboxItem): boolean { return ( !!item.matched_transaction_journal_entry_id && !!item.underlag_status && item.underlag_status !== 'anchored' ) } // One explanatory line per non-anchored status (#1548). 'unlinked' is the // only one the daily reconcile can heal on its own; the others say what // stands in the way instead of promising an automatic link. const UNDERLAG_STATUS_MESSAGE_KEY: Record, string> = { unlinked: 'underlag_unlinked', unlinked_locked: 'underlag_unlinked_locked', anchored_elsewhere: 'underlag_anchored_elsewhere', unknown: 'underlag_unknown', } function deriveInboxStatus(item: InboxItem): InboxStatus { if (item.created_supplier_invoice_id || item.created_journal_entry_id) return 'booked' if (item.matched_transaction_journal_entry_id && !isUnderlagDivergent(item)) return 'booked' // Staged upload mid-extraction. Outranks 'linked': a transaction-anchored // upload is matched from birth, but offering the booking bridge before the // fields exist would book from empty data. Transient (seconds): stays in // "Att göra" via the todo bucket rather than earning its own pill. if (item.status === 'processing') return 'processing' if (item.matched_transaction_id) return 'linked' if (item.status === 'error') return 'error' return 'needs_action' } // ── Skeleton ───────────────────────────────────────────────── // Shared with app/(dashboard)/e/[sector]/[slug]/loading.tsx so the route // fallback and this client-fetch shell are one silhouette with no reflow. const WorkspaceSkeleton = InvoiceInboxSkeleton // ── Main component ─────────────────────────────────────────── export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const { toast } = useToast() const t = useTranslations('inbox_workspace') const tStart = useTranslations('start_cards') const dismissKeyCompanyId = useCompanyOptional()?.company?.id ?? null const fileInputRef = useRef(null) // Its own input: sharing the header's would upload without the purchase. const purchaseFileInputRef = 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. // 'missing' is the odd one out: it lists bank purchases, not inbox items, so // the list and both panes branch on it. const [filter, setFilter] = useState< 'todo' | 'linked' | 'booked' | 'error' | 'all' | 'missing' | 'portal' >('todo') // Document-type filter (#2129): leverantörsfakturor vs underlag, on top of // the status filter. Not persisted, same as the status filter. const [kindFilter, setKindFilter] = useState('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 [docState, setDocState] = useState('none') // Monotonic tokens for the in-flight detail and document reads. The user // can click another row while one is running, but also re-request the SAME // item (action refreshes, the processing->received re-select effect), so an // id comparison is not enough: only the newest request of each kind may // paint its outcome (a detail snapshot, a URL, or an error) onto the pane. const detailRequestRef = useRef(0) const docRequestRef = useRef(0) 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. // The company's bookkeeping method drives the CTA hierarchy; read from the // session-cached settings row (lib/reference-data), no request of its own. const { settings: companySettings } = useCompanySettings() const accountingMethod: AccountingMethod = companySettings?.accounting_method === 'cash' ? 'cash' : '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() }, [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). Scoped per company: // dismissing the card on one company must not hide it on the user's other // companies. The legacy unscoped key is honored as "dismissed everywhere" // so users who dismissed before the scoping do not get the card back. useEffect(() => { if (typeof window === 'undefined') return try { const legacy = window.localStorage.getItem('gnubok.inbox.onboarding.dismissed') === '1' const scoped = dismissKeyCompanyId ? window.localStorage.getItem(`gnubok.inbox.onboarding.dismissed:${dismissKeyCompanyId}`) === '1' : false setOnboardingDismissed(legacy || scoped) } catch { // private browsing: keep default (show card) } }, [dismissKeyCompanyId]) const handleDismissOnboarding = useCallback(() => { try { window.localStorage.setItem( dismissKeyCompanyId ? `gnubok.inbox.onboarding.dismissed:${dismissKeyCompanyId}` : 'gnubok.inbox.onboarding.dismissed', '1', ) } catch { // ignore; in-memory state is enough for this session } setOnboardingDismissed(true) }, [dismissKeyCompanyId]) // 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) ─ // Purchases with no underlag at all. They are not inbox items and never // become them, so they live beside `items` rather than inside it: widening // InboxItem to cover a bank row would put a null document, a null extraction // and a null status through every consumer of that type. const [purchases, setPurchases] = useState([]) const [selectedPurchaseId, setSelectedPurchaseId] = useState(null) // Where underlag come from. Three routes in, and the page should say so: // the mailboxes we search, WhatsApp for photographed receipts, and the // forwarding address that works with nothing connected at all. const [mailConnections, setMailConnections] = useState([]) const [whatsapp, setWhatsapp] = useState<{ linked: boolean; phoneMasked?: string; verifiedAt?: string | null } | null>(null) const [sourcesOpen, setSourcesOpen] = useState(false) // Received-mail history (#2181): read when its panel is first opened, so // the sources strip costs nothing for people who never look. const [inboundMails, setInboundMails] = useState(null) const [inboundMailsFailed, setInboundMailsFailed] = useState(false) // The route caps the list; when the window held more, say so rather than // let "every mail" stand over a list that is missing the oldest ones. const [inboundMailsTruncated, setInboundMailsTruncated] = useState(false) const fetchInboundMails = useCallback(async () => { try { const res = await fetch( `/api/extensions/ext/invoice-inbox/inbound-history?days=${INBOUND_MAIL_DAYS}`, ) if (!res.ok) throw new Error(`inbound-history ${res.status}`) const { data } = await res.json() setInboundMails(Array.isArray(data?.mails) ? data.mails : []) setInboundMailsTruncated(data?.has_more === true) setInboundMailsFailed(false) } catch (err) { console.error('[invoice-inbox] fetchInboundMails failed:', err) setInboundMailsFailed(true) } }, []) useEffect(() => { void (async () => { try { const res = await fetch('/api/extensions/ext/mail/connections') if (!res.ok) return const json = (await res.json()) as { data?: { connections?: InboxMailConnection[] } } setMailConnections(json.data?.connections ?? []) } catch { // The extension may not be enabled at all; stay quiet. } })() void (async () => { try { const res = await fetch('/api/extensions/ext/whatsapp-inbox/link') if (!res.ok) return // The route answers in camelCase (phoneMasked / verifiedAt); reading // snake_case here silently rendered a linked number as "–". const json = (await res.json()) as { data?: { linked: boolean; phoneMasked?: string; verifiedAt?: string | null } } if (json.data) setWhatsapp(json.data) } catch { // Same: not every company has it. } })() }, []) // Counting rows would not answer whether anything is searchable: a revoked // or expired connection is still a row, and the hunt skips it, so the button // would promise a search that returns nothing every pass. A dead mailbox // looking healthy is the exact failure this feature exists to surface, so it // must not start by doing it in its own header. const mailConnected = useMemo( () => mailConnections.some((c) => c.status === 'active'), [mailConnections], ) const ailingMailbox = useMemo( () => mailConnections.find((c) => c.status !== 'active') ?? null, [mailConnections], ) const sourceCount = mailConnections.length + (whatsapp?.linked ? 1 : 0) + (inboxAddress ? 1 : 0) const fetchPurchases = useCallback(async () => { try { const res = await fetch('/api/extensions/ext/invoice-inbox/purchases') if (!res.ok) return const json = (await res.json()) as { data: { purchases: PurchaseWithoutUnderlag[] } } setPurchases(json.data.purchases ?? []) } catch { // A missing count is better than an error beside the user's documents. } }, []) useEffect(() => { void fetchPurchases() }, [fetchPurchases]) // A pass can attach a document to a purchase, which moves a row from one // list to the other, so both refresh as the run goes rather than at the end. const { hunt, stop: stopHunt, hunting, progress: huntProgress, result: huntResult, setResult: setHuntResult, } = useReceiptHunt(() => { void fetchItems() void fetchPurchases() }) // A run that found nothing leaves nothing to act on, so the line has no // reason to outlive the glance that reads it. A run that found something, // or failed, stays: both name a next step (press again, or a mailbox to // check) and both are worth still being on screen a minute later. useEffect(() => { if (hunting || !huntResult) return if (huntResult.failed || huntResult.fetched > 0) return const timer = setTimeout(() => setHuntResult(null), 6000) return () => clearTimeout(timer) }, [hunting, huntResult, setHuntResult]) const selectedPurchase = useMemo( () => purchases.find((p) => p.id === selectedPurchaseId) ?? null, [purchases, selectedPurchaseId], ) const portalPurchases = useMemo(() => purchases.filter((p) => p.portal), [purchases]) const otherPurchases = useMemo(() => purchases.filter((p) => !p.portal), [purchases]) // 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 }, ] // Two lists, because they are two different jobs. A purchase whose // supplier keeps invoices behind a login is one you can settle now by // going there; one with nothing known needs somebody to be asked. Mixing // them buries the twelve you can act on among the hundred you cannot. if (portalPurchases.length > 0 || filter === 'portal') { list.push({ key: 'portal', label: t('filter_portal'), count: portalPurchases.length }) } if (otherPurchases.length > 0 || filter === 'missing') { list.push({ key: 'missing', label: t('filter_missing'), count: otherPurchases.length }) } 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, portalPurchases.length, otherPurchases.length]) const activePill = useMemo(() => pills.find((p) => p.key === filter), [pills, filter]) const filteredPurchases = useMemo(() => { const base = filter === 'portal' ? portalPurchases : otherPurchases const term = searchTerm.trim().toLowerCase() if (term === '') return base return base.filter((p) => [p.merchant_name, p.description].some((v) => v?.toLowerCase().includes(term)), ) }, [portalPurchases, otherPurchases, filter, searchTerm]) const statusFilteredItems = useMemo(() => { if (filter === 'missing' || filter === 'portal') return [] 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 return true }) }, [items, filter]) // Per-kind counts for the type menu, over the status-filtered list so the // numbers match what picking an entry would show. const kindCounts = useMemo(() => { const counts: Record = { all: statusFilteredItems.length, supplier_invoice: 0, underlag: 0, } for (const item of statusFilteredItems) { const kind = resolveInboxKind(item) if (matchesInboxKindFilter(kind, 'supplier_invoice')) counts.supplier_invoice += 1 else if (matchesInboxKindFilter(kind, 'underlag')) counts.underlag += 1 } return counts }, [statusFilteredItems]) // Rows the type menu is hiding right now (#2181): a +lev mail filed as a // leverantörsfaktura is invisible under Underlag, and the trigger's count // alone does not say that anything is missing. const hiddenByKindFilter = kindFilter === 'all' ? 0 : kindCounts.all - kindCounts[kindFilter] // The type menu only earns its row once something is classified (or the // user has already narrowed): an inbox of unclassified rows has nothing to // split. const showKindFilter = filter !== 'missing' && filter !== 'portal' && (kindFilter !== 'all' || kindCounts.supplier_invoice > 0 || kindCounts.underlag > 0) const filteredItems = useMemo(() => { const term = searchTerm.trim().toLowerCase() return statusFilteredItems.filter((item) => { // Type filter: sender hint first, then the AI classification. if (!matchesInboxKindFilter(resolveInboxKind(item), kindFilter)) 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) }) }, [statusFilteredItems, kindFilter, 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 (documentId: string | null) => { const request = ++docRequestRef.current 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 !== request) return if (!res.ok) { setDocState('error') return } const { data } = await res.json() if (docRequestRef.current !== request) return // Always preview via the same-origin inline proxy. Signed Storage URLs // are served as Content-Disposition: attachment, which Chrome blocks in // iframe/img with "Det här innehållet har blockerats". setDocUrl(`/api/documents/${documentId}/inline`) 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 !== request) return setDocState('error') } }, []) const handleSelect = useCallback(async (id: string) => { const request = ++detailRequestRef.current setSelectedId(id) setSelectedPurchaseId(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. // Seed the detail pane synchronously from the list row already in hand // (fetchItems returns full rows: status, amounts, extracted fields), and // start the document load in parallel with the detail GET. Clearing // `selected` first made every row click flash the no-selection branch // (onboarding card / "Välj en post") for a full round trip, then run a // second serialized round trip before the PDF even started loading. const listRow = items.find((it) => it.id === id) ?? null if (listRow) { setSelected(listRow) void loadDocument(listRow.document_id) } else { // Invalidate any in-flight document read: the pane is being cleared, // and a late resolution must not paint a URL or error onto it. docRequestRef.current++ setSelected(null) setDocUrl(null) setDocMime(null) setDocState('none') } try { const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${id}`) if (!res.ok) throw await resolveFailure(res) const json = await res.json() const item = json.data as InboxItem // A newer selection owns the pane now: dropping this response keeps a // slower earlier fetch (same item or another) from overwriting the // newest request's detail snapshot. if (detailRequestRef.current !== request) return setSelected(item) if (!listRow) { await loadDocument(item.document_id) } else if (item.document_id !== listRow.document_id) { // The detail row knows a different underlag than the list row we // seeded from (e.g. processing finished between paint and click). void loadDocument(item.document_id) } } catch (err) { if (detailRequestRef.current !== request) return toast({ title: 'Kunde inte ladda dokumentet', description: failureText(err), variant: 'destructive', }) } }, [items, toast, loadDocument]) // The detail pane renders from its own fetched snapshot (`selected`), so // the realtime refetch updates the list row but would leave a selected // staged upload stuck on the in-flight skeleton after the processing -> // received flip. Re-read the detail when the list shows the flip landed. useEffect(() => { if (!selected || selected.isPlaceholder || selected.status !== 'processing') return const listRow = items.find((it) => it.id === selected.id) if (listRow && listRow.status !== 'processing') { void handleSelect(selected.id) } }, [items, selected, handleSelect]) // ── 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 ( original: File, options: { autoSelect: boolean } = { autoSelect: true }, ) => { // A phone photo is routinely larger than the request body the platform // will carry, and it rejects the upload itself, before the route can say // anything useful about it. Shrink what can be shrunk; what cannot be (a // scanned PDF) goes straight to Storage through a signed URL instead of a // multipart body. Only the inbox's own ceiling refuses anything now, here, // where we can name the size instead of letting the transfer fail. const file = exceedsHostedUploadLimit(original.size) ? await shrinkImageForUpload(original) : original if (exceedsInboxUploadLimit(file.size)) { reportUploadFailure({ status: 0, size: file.size, type: file.type || 'unknown', reason: 'over inbox ceiling, refused client-side', }) toast({ title: 'Uppladdning misslyckades', description: inboxTooLargeMessage(file.size), variant: 'destructive', }) return undefined } const directToStorage = exceedsHostedUploadLimit(file.size) // 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 { let res: Response if (directToStorage) { res = await uploadViaSignedUrl(file) } else { const fd = new FormData() fd.append('file', file) res = await fetch('/api/extensions/ext/invoice-inbox/upload', { method: 'POST', body: fd, }) } if (!res.ok) throw await resolveFailure(res) const json = await res.json() 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) } return json.data?.inbox_item_id as string | undefined } 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)) } const reason = failureText(err) reportUploadFailure({ status: err instanceof ResolvedFailure ? err.status : 0, size: file.size, type: file.type || 'unknown', reason, }) toast({ title: 'Uppladdning misslyckades', description: reason, 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. /** * Upload a file and make it the underlag for one specific purchase. * * The generic upload only carries the file, so a document dropped while a * purchase was selected landed in the inbox unmatched: the pane showed that * purchase's amount and date under the drop zone and then quietly did not * use either. Matching afterwards through the endpoint that already exists * keeps the promise the copy makes. */ const uploadForPurchase = useCallback(async (files: File[], transactionId: string) => { const [file, ...rest] = files if (!file) return const itemId = await uploadFile(file, { autoSelect: false }) // A receipt scanned as two images, or an invoice with its specification, // arrives as one drop. Taking the first and discarding the rest in silence // left the purchase looking resolved with half its paperwork gone. They // cannot all be the underlag for one purchase, so the extras are filed in // the inbox rather than dropped on the floor. for (const extra of rest) await uploadFile(extra, { autoSelect: false }) if (!itemId) return try { const res = await fetch( `/api/extensions/ext/invoice-inbox/items/${itemId}/match-transaction`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: transactionId }), }, ) if (!res.ok) throw await resolveFailure(res) toast({ title: 'Underlag kopplat', description: rest.length ? `${file.name}. ${rest.length} till lades i inkorgen.` : file.name, }) setSelectedPurchaseId(null) await Promise.all([fetchItems(), fetchPurchases()]) } catch (err) { // The document is safely filed either way; only the link failed, and // the user can still make it by hand from the inbox. toast({ title: 'Uppladdat, men inte kopplat', description: err instanceof ResolvedFailure ? `${failureText(err)} Dokumentet ligger i inkorgen, koppla det till köpet därifrån.` : 'Dokumentet ligger i inkorgen. Koppla det till köpet därifrån.', variant: 'destructive', }) await fetchItems() } }, [uploadFile, toast, fetchItems, fetchPurchases]) 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) return // Dropping while a purchase is selected means "this is that purchase's // receipt", wherever on the page it landed. Ignoring the selection would // file it loose and leave the user to match by hand what they had already // told us. if (selectedPurchaseId) { await uploadForPurchase(files, selectedPurchaseId) return } await uploadFiles(files) }, [uploadFiles, uploadForPurchase, selectedPurchaseId]) // ── 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', }) if (!res.ok) throw await resolveFailure(res) toast({ title: 'Borttagen' }) if (selectedId === id) { setSelectedId(null) setSelected(null) } await fetchItems() } catch (err) { toast({ title: 'Kunde inte ta bort', description: failureText(err), variant: 'destructive', }) } finally { setIsDeleting(false) } }, [fetchItems, selectedId, toast]) // Ranges walk the rendered inbox rows in order. Optimistic upload // placeholders render no checkbox, so they stay out of the range: their // temp-* ids are not server rows and must never reach a bulk action. const range = useRangeSelect({ visibleIds: filteredItems.filter((item) => !item.isPlaceholder).map((item) => item.id), selectedIds, setSelectedIds, }) const toggleSelected = useCallback( (id: string, extend?: boolean) => range.toggle(id, extend), [range], ) const clearSelection = useCallback(() => { setSelectedIds(new Set()) range.resetAnchor() }, [range]) // 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 && // A matched transaction that is already booked has nothing left to // bulk-book: the server would only skip it with a 409. !it.matched_transaction_journal_entry_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', }) if (!res.ok) throw await resolveFailure(res) const json = await res.json() setInboxAddress(json.data) setAddressLoadFailed(false) toast({ title: 'Ny adress skapad', description: json.data.address }) } catch (err) { toast({ title: 'Rotation misslyckades', description: failureText(err), 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} > {/* No card of its own: /e/ routes render full-bleed inside the dashboard panel, which already supplies the border, the 12px radius and the background. Wrapping the workspace in a second rounded, bordered surface drew two frames 24px apart with mismatched radii. */}
{/* Top bar */}

Dokumentinkorg

{/* Where the page's contents come from, behind one chip. The detail (which mailbox, when it was last read) is a thing people look up when something seems wrong, not something they read every visit. A mailbox that has stopped working is the exception, so that surfaces on the chip itself. */} {sourceCount > 0 ? ( ) : 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')}
) : ( )}
{/* No image/heic or image/heif here on purpose: when HEIC is absent from accept, iOS Safari transcodes photo-library picks to JPEG, which AI extraction can read. The server allowlist still accepts HEIC for drag-drop and the email/WhatsApp channels. */} {/* The hunt lived only in Settings, so the button that fills this page sat on a different page. It runs in passes and reports as it goes, because a backlog does not clear in one request. */} {mailConnected && ( )}
{/* A pass takes over two minutes and reports nothing until it lands, so a spinner alone leaves somebody watching a button. This says which mailboxes are being read, what has been found so far, and keeps moving while the pass is silent. The bar is deliberately indeterminate: there is no honest percentage inside a pass, and a fake one is worse than none. */} {hunting && (
{t('hunt_reading', { mailboxes: mailConnections .filter((c) => c.status === 'active') .map((c) => c.emailAddress) .join(', '), })} {huntProgress && ( {t('hunt_progress', { pass: huntProgress.passes, found: huntProgress.fetched, })} )}
)} {!hunting && huntResult && (
{huntResult.failed ? ( {/* searchFailures counts mailboxes that refused; without it the failure was ours, and telling somebody to go check a healthy Gmail sends them after the wrong thing. */} {(huntResult.searchFailures ?? 0) > 0 ? 'En brevlåda svarade inte. Försök igen om en stund.' : 'Sökningen kunde inte slutföras. Försök igen.'} ) : huntResult.fetched > 0 ? ( {huntResult.fetched} nya underlag hämtade.{' '} {huntResult.remaining > 0 && ( <> {huntResult.remaining} köp kvar att söka för: tryck igen.{' '} )} {/* "proposed" counts pending_operations rows, not links. The hunt stages attach_document_to_transaction for a human to approve and books nothing, so calling them kopplade would send the user away believing purchases were done. */} {huntResult.proposed > 0 ? ( <> {huntResult.proposed} förslag väntar på{' '} granskning . ) : ( // A press fetches a bounded number of receipts, so an empty // result usually means "not yet", not "nothing there". Saying // only the first sends people away from a mailbox that still // has their receipts in it. <>Inget matchade något köp än. Tryck igen för att leta vidare. )} ) : ( Inga nya underlag i brevlådorna för de köp som saknar ett. )}
)} {/* Opened from the chip. Three ways in, each with the one fact that matters about it: an address you can forward to, mailboxes we search, and the number receipts arrive from. Nothing here is configuration; that still lives in Inställningar. */} {sourcesOpen && (
{inboxAddress && (
{inboxAddress.address} {/* Plus-addressing (#2129): the sender sorts the mail by writing +lev or +ver before the @. Both variants spelled out, since a tag is easier to copy than to construct. */}

{t('address_plus_hint', { lev: plusAddress(inboxAddress.address, 'lev'), ver: plusAddress(inboxAddress.address, 'ver'), })}

)} {/* Every mail that reached the address (#2181), whatever became of it: filed, duplicate, rejected, failed. This is where "I mailed it and it is not there" gets an answer instead of a shrug. */} {inboxAddress && (
{ if (e.currentTarget.open && inboundMails === null && !inboundMailsFailed) { void fetchInboundMails() } }} > {t('inbound_mail_title')} {inboundMails !== null && ( {inboundMails.length} )}

{inboundMailsTruncated && inboundMails ? t('inbound_mail_truncated', { count: inboundMails.length, days: INBOUND_MAIL_DAYS }) : t('inbound_mail_hint', { days: INBOUND_MAIL_DAYS })}

{inboundMailsFailed ? ( { void fetchInboundMails() } }} > {t('inbound_mail_load_failed')} ) : inboundMails === null ? ( {t('inbound_mail_loading')} ) : inboundMails.length === 0 ? (

{t('inbound_mail_empty', { days: INBOUND_MAIL_DAYS })}

) : (
    {inboundMails.map((mail) => ( ))}
)}
)} {mailConnections.map((c) => (
{c.provider === 'gmail' ? ( ) : ( )} {c.emailAddress} {c.status !== 'active' && ( Behöver återanslutas )} {/* One level down, because this is what you look up when a mailbox seems to have gone quiet, not what you read on the way past. */}
{t('source_last_searched')}
{c.lastSearchedAt ? formatDateLong(c.lastSearchedAt) : t('source_never_searched')}
Status
{c.status === 'active' ? t('source_searched_when_hunting') : t('source_not_searched')}
))} {whatsapp?.linked && (
WhatsApp
{t('source_whatsapp_number')}
{whatsapp.phoneMasked ?? '-'}
Status
{whatsapp.verifiedAt ? t('source_verified') : t('source_unverified')}
)}
)} {/* 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. */}
{selectedPurchase ? ( // There is no file to show, so the pane says why and then offers // the one thing that resolves it. Telling somebody a document is // missing without a place to put it is half an answer.

{t('purchase_no_document')}

{selectedPurchase.portal ? `${selectedPurchase.portal.vendor} skickar ingen fil. Hämta fakturan och släpp den här.` : 'Vi har sökt i brevlådorna. Släpp kvittot här, eller vidarebefordra det till inkorgsadressen.'}

{selectedPurchase.portal && ( )} {/* Same accept list as the header input: HEIC left out so iOS delivers JPEG from the photo library. */} { const files = Array.from(e.target.files ?? []) if (files.length > 0) await uploadForPurchase(files, selectedPurchase.id) if (purchaseFileInputRef.current) purchaseFileInputRef.current.value = '' }} />
) : selected ? ( { void loadDocument(selected.document_id) }} /> ) : showOnboarding ? (
fileInputRef.current?.click(), }} onDismiss={handleDismissOnboarding} dismissLabel={tStart('inbox_dismiss')} />

{t('drop_anywhere_hint')}

) : ( 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 InboundMailRow({ mail, domain, onOpenItem, }: { mail: InboundMail /** The shared inbound domain, from the company's own address. */ domain: string onOpenItem: (id: string) => void }) { const t = useTranslations('inbox_workspace') // The address is reconstructed from the inbox row, never read from the // event: one line per tag the mail used, or the bare address. const tags = mail.tags ?? [] const address = mail.custom_domain ? t('inbound_mail_custom_domain') : mail.inbox_local_part ? (tags.length > 0 ? tags : [null]) .map((tag) => `${mail.inbox_local_part}${tag ? `+${tag}` : ''}@${domain}`) .join(', ') : t('inbound_mail_former_address') const counts = { filed: 0, duplicate: 0, rejected: 0, failed: 0 } for (const a of mail.attachments ?? []) { if (a.outcome in counts) counts[a.outcome] += 1 } const parts: string[] = [] if (mail.outcome === 'rate_limited') parts.push(t('inbound_outcome_rate_limited')) else if (mail.outcome === 'no_attachments') parts.push(t('inbound_outcome_empty')) else if (mail.outcome === 'email_body') parts.push(t('inbound_outcome_body')) else if (mail.outcome === 'email_body_duplicate') parts.push(t('inbound_outcome_body_duplicate')) else if (mail.outcome === 'fan_out_capped') parts.push(t('inbound_outcome_fan_out_capped')) else { if (counts.filed > 0) parts.push(t('inbound_outcome_filed', { count: counts.filed })) if (counts.duplicate > 0) parts.push(t('inbound_outcome_duplicate', { count: counts.duplicate })) if (counts.rejected > 0) parts.push(t('inbound_outcome_rejected', { count: counts.rejected })) if (counts.failed > 0) parts.push(t('inbound_outcome_failed', { count: counts.failed })) } const hasFailure = mail.outcome === 'rate_limited' || mail.outcome === 'fan_out_capped' || counts.rejected > 0 || counts.failed > 0 // Every row the mail produced, in attachment order, each a click away. const openable: string[] = [] if (mail.inbox_item_id) openable.push(mail.inbox_item_id) for (const a of mail.attachments ?? []) { if (a.inbox_item_id && !openable.includes(a.inbox_item_id)) openable.push(a.inbox_item_id) } return (
  • {formatDateTime(mail.occurred_at)} {address} {(mail.unknown_tag_count ?? 0) > 0 && ( {t('inbound_unknown_tags', { count: mail.unknown_tag_count })} )}
    {parts.join(', ')} {openable.map((id, i) => ( ))}
    {mail.tag_conflict && {t('inbound_tag_conflict')}}
  • ) } 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: (extend?: boolean) => 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') // Radix' onCheckedChange carries no mouse event: the preceding click records // whether shift was held, for range selection. const shiftHeld = useRef(false) const amount = pickAmount(item) const supplierName = pickSupplierName(item) const invoiceDate = pickInvoiceDate(item) const isPlaceholder = !!item.isPlaceholder const kind = resolveInboxKind(item) const status = deriveInboxStatus(item) const isErrored = status === 'error' const isBooked = status === 'booked' const isLinkedToTransaction = status === 'linked' // Staged upload: the row is real (that IS the "mottaget" ack) but the // deferred AI extraction is still in flight. The realtime refetch flips it. const isExtracting = status === 'processing' // 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' const receivedMeta = ( {timeAgo(item.email_received_at ?? item.created_at)} {invoiceDate && ( <> · {formatDate(invoiceDate)} )} ) return (
  • {!isPlaceholder && (
    e.stopPropagation()} > { shiftHeld.current = e.shiftKey }} onCheckedChange={() => onToggleChecked(shiftHeld.current)} aria-label="Markera post" className="h-3.5 w-3.5 border-foreground" />
    )}
  • ) } // ── 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
    ) : docMime === 'text/html' ? ( // HTML mail underlag: arbitrary sender-controlled markup. sandbox // with no tokens = opaque origin, no scripts, no forms, no popups. // bg-white because mail HTML assumes a white canvas and would render // transparent (unreadable in dark mode) otherwise.