Släpp filen för att ladda upp
'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 (
Släpp filen för att ladda upp
{address}
Underlagen samlas här (från mail eller filuppladdning) och kan matchas mot bankhändelser eller bokföras direkt. Inkorgen är alltid gratis; AI-tolkning av underlag ingår i abonnemanget.
{step.title}
{!isDone && ({step.hint}
)}…eller maila underlagen till din inkorgsadress.
Välj en post i listan för att matcha eller bokföra.
)}{onActivateInbox ? 'Aktivera din inkorgsadress' : 'Välj ett dokument från listan'}
{onActivateInbox ? 'Ditt bolag får en unik e-postadress som leverantörer kan skicka fakturor till.' : 'Eller dra och släpp en fil var som helst på sidan för att ladda upp.'}
Fel vid bearbetning
{item.error_message}
{item.email_body_text}
) : (
{t('email_body_empty')}
)}Uppgradera för att låta Accounted läsa av leverantör, belopp och moms automatiskt. Du kan fortfarande fylla i fälten manuellt eller koppla dokumentet till en transaktion nedan.
Momsfördelning
Posten är kopplad till en leverantörsfaktura: fälten kan inte ändras.
)}