diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index f41ff124..0228f0d9 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -23,7 +23,7 @@ import DescribeTransactionDialog from '@/components/transactions/DescribeTransac import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' -import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types' +import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem } from '@/types' import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions' export default function TransactionsPage() { @@ -114,9 +114,32 @@ export default function TransactionsPage() { } } + // Fetch matched inbox items for unbooked transactions + const unbookedTxIds = (txData || []) + .filter((t) => !t.journal_entry_id && t.is_business === null) + .map((t) => t.id) + + let inboxItemMap: Record = {} + if (unbookedTxIds.length > 0) { + const { data: inboxItems } = await supabase + .from('invoice_inbox_items') + .select('*') + .in('matched_transaction_id', unbookedTxIds) + .in('status', ['ready', 'processing']) + if (inboxItems) { + inboxItemMap = inboxItems.reduce((acc, item) => { + if (item.matched_transaction_id) { + acc[item.matched_transaction_id] = item as InvoiceInboxItem + } + return acc + }, {} as Record) + } + } + const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({ ...t, potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined, + matched_inbox_item: inboxItemMap[t.id] || undefined, })) setTransactions(transactionsWithInvoices) @@ -176,7 +199,7 @@ export default function TransactionsPage() { } }, [transactions.length]) - const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride) => { + const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => { try { setProcessingId(id) const response = await fetch(`/api/transactions/${id}/categorize`, { @@ -187,6 +210,8 @@ export default function TransactionsPage() { category, vat_treatment: vatTreatment, account_override: accountOverride, + template_id: templateId, + inbox_item_id: inboxItemId, }), }) @@ -446,6 +471,7 @@ export default function TransactionsPage() { async function openSwipeView() { try { + // Match invoices to transactions await fetch('/api/transactions/batch-match-invoices', { method: 'POST' }) .then((r) => r.json()) .then((data) => { @@ -454,6 +480,16 @@ export default function TransactionsPage() { } catch { // Non-critical } + try { + // Run document matching sweep for latest inbox matches + await fetch('/api/documents/match-sweep', { method: 'POST' }) + .then((r) => r.json()) + .then((data) => { + if (data.data?.matched > 0) fetchTransactions() + }) + } catch { + // Non-critical + } const uncatIds = uncategorizedTransactions.map((t) => t.id) await fetchCategorySuggestions(uncatIds) setShowSwipeView(true) diff --git a/app/api/documents/match-sweep/route.ts b/app/api/documents/match-sweep/route.ts new file mode 100644 index 00000000..c1562317 --- /dev/null +++ b/app/api/documents/match-sweep/route.ts @@ -0,0 +1,31 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { runDocumentMatchingSweep } from '@/lib/documents/batch-match' + +export async function POST(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Optional: pass specific inbox item IDs to match + let inboxItemIds: string[] | undefined + try { + const body = await request.json() + if (Array.isArray(body?.inboxItemIds)) { + inboxItemIds = body.inboxItemIds + } + } catch { + // No body or invalid JSON — sweep all unmatched items + } + + try { + const result = await runDocumentMatchingSweep(supabase, user.id, inboxItemIds) + return NextResponse.json({ data: result }) + } catch (error) { + console.error('[match-sweep] Failed:', error) + return NextResponse.json({ error: 'Match sweep failed' }, { status: 500 }) + } +} diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm-receipt/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm-receipt/route.ts new file mode 100644 index 00000000..076bfe0b --- /dev/null +++ b/app/api/extensions/invoice-inbox/inbox/[id]/confirm-receipt/route.ts @@ -0,0 +1,148 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { eventBus } from '@/lib/events/bus' + +ensureInitialized() + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + // Fetch inbox item + const { data: inboxItem, error: findError } = await supabase + .from('invoice_inbox_items') + .select('*') + .eq('id', id) + .eq('user_id', user.id) + .single() + + if (findError || !inboxItem) { + return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 }) + } + + if (inboxItem.document_type !== 'receipt') { + return NextResponse.json({ error: 'Inbox item is not a receipt' }, { status: 400 }) + } + + if (!inboxItem.linked_receipt_id) { + return NextResponse.json({ error: 'No linked receipt found' }, { status: 400 }) + } + + const body = await request.json() + const { + line_items, + matched_transaction_id, + representation_persons, + representation_purpose, + representation_business_connection, + } = body + + // Update receipt line items (business/private classification) + if (Array.isArray(line_items)) { + for (const item of line_items) { + if (!item.id) continue + await supabase + .from('receipt_line_items') + .update({ + is_business: item.is_business, + ...(item.category ? { category: item.category } : {}), + ...(item.bas_account ? { bas_account: item.bas_account } : {}), + }) + .eq('id', item.id) + .eq('receipt_id', inboxItem.linked_receipt_id) + } + } + + // Calculate business/private totals + const { data: updatedLineItems } = await supabase + .from('receipt_line_items') + .select('*') + .eq('receipt_id', inboxItem.linked_receipt_id) + + let businessTotal = 0 + let privateTotal = 0 + if (updatedLineItems) { + for (const li of updatedLineItems) { + if (li.is_business === true) { + businessTotal += li.line_total + } else if (li.is_business === false) { + privateTotal += li.line_total + } + } + } + businessTotal = Math.round(businessTotal * 100) / 100 + privateTotal = Math.round(privateTotal * 100) / 100 + + // Update receipt with match and representation data + const receiptUpdate: Record = { + status: 'confirmed', + } + + if (matched_transaction_id) { + receiptUpdate.matched_transaction_id = matched_transaction_id + } + if (representation_persons != null) { + receiptUpdate.representation_persons = representation_persons + } + if (representation_purpose) { + receiptUpdate.representation_purpose = representation_purpose + } + if (representation_business_connection) { + receiptUpdate.representation_business_connection = representation_business_connection + } + + await supabase + .from('receipts') + .update(receiptUpdate) + .eq('id', inboxItem.linked_receipt_id) + + // Link transaction to receipt if provided + if (matched_transaction_id) { + await supabase + .from('transactions') + .update({ receipt_id: inboxItem.linked_receipt_id }) + .eq('id', matched_transaction_id) + .eq('user_id', user.id) + } + + // Update inbox item status + await supabase + .from('invoice_inbox_items') + .update({ status: 'confirmed' }) + .eq('id', id) + + // Emit event (non-blocking) + try { + const { data: receipt } = await supabase + .from('receipts') + .select('*') + .eq('id', inboxItem.linked_receipt_id) + .single() + + if (receipt) { + await eventBus.emit({ + type: 'receipt.confirmed', + payload: { + receipt, + businessTotal, + privateTotal, + userId: user.id, + }, + }) + } + } catch { + // Non-blocking + } + + return NextResponse.json({ data: { confirmed: true, businessTotal, privateTotal } }) +} diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts index 62badf33..dc8c68e9 100644 --- a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts +++ b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts @@ -49,7 +49,7 @@ export async function POST( if (!supplierId) { // Create new supplier from extracted data - const supplierName = extraction.supplier.name + const supplierName = extraction.supplier?.name if (!supplierName) { return NextResponse.json({ error: 'Supplier name is required' }, { status: 400 }) } @@ -60,13 +60,13 @@ export async function POST( user_id: user.id, name: supplierName, supplier_type: 'swedish_business', - org_number: extraction.supplier.orgNumber || null, - vat_number: extraction.supplier.vatNumber || null, - bankgiro: extraction.supplier.bankgiro || null, - plusgiro: extraction.supplier.plusgiro || null, + org_number: extraction.supplier?.orgNumber || null, + vat_number: extraction.supplier?.vatNumber || null, + bankgiro: extraction.supplier?.bankgiro || null, + plusgiro: extraction.supplier?.plusgiro || null, default_expense_account: '6200', default_payment_terms: 30, - default_currency: extraction.invoice.currency || 'SEK', + default_currency: extraction.invoice?.currency || 'SEK', }) .select() .single() @@ -99,7 +99,7 @@ export async function POST( } // Build line items from extraction - const items = extraction.lineItems.map((item, index) => { + const items = (extraction.lineItems || []).map((item, index) => { const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25 const lineTotal = Math.round(item.lineTotal * 100) / 100 const vatAmount = Math.round(lineTotal * vatRate * 100) / 100 @@ -118,7 +118,7 @@ export async function POST( }) // If no line items, create a single item from totals - if (items.length === 0 && extraction.totals.total) { + if (items.length === 0 && extraction.totals?.total) { const total = extraction.totals.total const vatAmount = extraction.totals.vatAmount || 0 const subtotal = extraction.totals.subtotal || total - vatAmount @@ -155,13 +155,13 @@ export async function POST( user_id: user.id, supplier_id: supplierId, arrival_number: arrivalNum, - supplier_invoice_number: extraction.invoice.invoiceNumber || `INBOX-${Date.now()}`, - invoice_date: extraction.invoice.invoiceDate || new Date().toISOString().split('T')[0], - due_date: extraction.invoice.dueDate || new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0], + supplier_invoice_number: extraction.invoice?.invoiceNumber || `INBOX-${Date.now()}`, + invoice_date: extraction.invoice?.invoiceDate || new Date().toISOString().split('T')[0], + due_date: extraction.invoice?.dueDate || new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0], status: 'registered', - currency: extraction.invoice.currency || 'SEK', + currency: extraction.invoice?.currency || 'SEK', vat_treatment: vatTreatment, - payment_reference: extraction.invoice.paymentReference || null, + payment_reference: extraction.invoice?.paymentReference || null, subtotal: Math.round(subtotal * 100) / 100, vat_amount: Math.round(vatAmount * 100) / 100, total: Math.round(total * 100) / 100, diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts index a02fc6a1..758c0544 100644 --- a/app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts +++ b/app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts @@ -5,6 +5,8 @@ import { eventBus } from '@/lib/events/bus' import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer' import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher' import { getSettings } from '@/extensions/general/invoice-inbox' +import { matchDocumentToTransactions } from '@/lib/documents/document-matcher' +import type { InvoiceInboxItem } from '@/types' ensureInitialized() @@ -87,16 +89,27 @@ export async function POST( } } - // Update inbox item + // Update inbox item with extraction + template suggestion + const updateData: Record = { + status: 'ready', + extracted_data: extraction as unknown as Record, + confidence: extraction.confidence, + matched_supplier_id: matchedSupplierId, + error_message: null, + // Reset previous match on re-process + matched_transaction_id: null, + match_confidence: null, + match_method: null, + } + + if (extraction.suggestedTemplateId) { + updateData.suggested_template_id = extraction.suggestedTemplateId + updateData.suggested_template_confidence = extraction.confidence + } + const { data: updatedItem, error: updateError } = await supabase .from('invoice_inbox_items') - .update({ - status: 'ready', - extracted_data: extraction as unknown as Record, - confidence: extraction.confidence, - matched_supplier_id: matchedSupplierId, - error_message: null, - }) + .update(updateData) .eq('id', id) .select() .single() @@ -114,6 +127,28 @@ export async function POST( userId: user.id, }, }) + + // Document-to-transaction matching (non-blocking) + try { + const matchResult = await matchDocumentToTransactions( + supabase, + user.id, + updatedItem as InvoiceInboxItem + ) + + if (matchResult) { + await supabase + .from('invoice_inbox_items') + .update({ + matched_transaction_id: matchResult.transactionId, + match_confidence: matchResult.confidence, + match_method: matchResult.method, + }) + .eq('id', id) + } + } catch (matchError) { + console.error('[invoice-inbox] Transaction matching failed:', matchError) + } } return NextResponse.json({ data: updatedItem }) diff --git a/app/api/extensions/invoice-inbox/inbox/route.ts b/app/api/extensions/invoice-inbox/inbox/route.ts index a8b5d37c..a984f55c 100644 --- a/app/api/extensions/invoice-inbox/inbox/route.ts +++ b/app/api/extensions/invoice-inbox/inbox/route.ts @@ -5,6 +5,8 @@ import { eventBus } from '@/lib/events/bus' import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer' import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher' import { getSettings } from '@/extensions/general/invoice-inbox' +import { matchDocumentToTransactions } from '@/lib/documents/document-matcher' +import type { InvoiceInboxItem, InvoiceExtractionResult } from '@/types' import crypto from 'crypto' ensureInitialized() @@ -19,16 +21,21 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const status = searchParams.get('status') + const documentType = searchParams.get('document_type') let query = supabase .from('invoice_inbox_items') - .select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name)') + .select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name), receipt:receipts(id, merchant_name, total_amount, receipt_date, status, matched_transaction_id)') .eq('user_id', user.id) if (status && status !== 'all') { query = query.eq('status', status) } + if (documentType && documentType !== 'all') { + query = query.eq('document_type', documentType) + } + const { data, error } = await query.order('created_at', { ascending: false }) if (error) { @@ -47,85 +54,113 @@ export async function POST(request: Request) { } const formData = await request.formData() - const file = formData.get('file') as File | null - if (!file) { + // Support batch upload: multiple `files` entries, fallback to single `file` + const files: File[] = [] + const multiFiles = formData.getAll('files') + if (multiFiles.length > 0) { + for (const f of multiFiles) { + if (f instanceof File) files.push(f) + } + } else { + const single = formData.get('file') as File | null + if (single) files.push(single) + } + + if (files.length === 0) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }) } const supportedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] - if (!supportedTypes.includes(file.type)) { - return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 }) + const items: Array> = [] + const errors: string[] = [] + + for (const file of files) { + if (!supportedTypes.includes(file.type)) { + errors.push(`${file.name}: unsupported file type`) + continue + } + + try { + const result = await uploadAndCreateInboxItem(supabase, user.id, file) + items.push(result.inboxItem) + + // Process asynchronously + processInboxItem(result.inboxItem.id as string, user.id, result.base64, file.type).catch((err) => + console.error('[invoice-inbox] Background processing failed:', err) + ) + } catch (error) { + const message = error instanceof Error ? error.message : 'Upload failed' + errors.push(`${file.name}: ${message}`) + } } - try { - // Read file - const arrayBuffer = await file.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - const base64 = buffer.toString('base64') - const hash = crypto.createHash('sha256').update(buffer).digest('hex') + // Return array for batch, single item for backward compat + if (files.length === 1 && items.length === 1) { + return NextResponse.json({ data: items[0] }) + } - // Upload to storage - const storagePath = `documents/${user.id}/inbox/${Date.now()}-${file.name}` - const { error: uploadError } = await supabase.storage - .from('documents') - .upload(storagePath, buffer, { contentType: file.type }) + return NextResponse.json({ data: items, errors: errors.length > 0 ? errors : undefined }) +} - if (uploadError) { - return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 }) - } +async function uploadAndCreateInboxItem( + supabase: Awaited>, + userId: string, + file: File +): Promise<{ inboxItem: Record; base64: string }> { + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const base64 = buffer.toString('base64') + const hash = crypto.createHash('sha256').update(buffer).digest('hex') - // Create document attachment record - const { data: document, error: docError } = await supabase - .from('document_attachments') - .insert({ - user_id: user.id, - storage_path: storagePath, - file_name: file.name, - file_size_bytes: buffer.length, - mime_type: file.type, - sha256_hash: hash, - upload_source: 'file_upload', - }) - .select() - .single() + const storagePath = `documents/${userId}/inbox/${Date.now()}-${file.name}` + const { error: uploadError } = await supabase.storage + .from('documents') + .upload(storagePath, buffer, { contentType: file.type }) - if (docError || !document) { - return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 }) - } + if (uploadError) { + throw new Error('Failed to upload file') + } - // Create inbox item - const { data: inboxItem, error: itemError } = await supabase - .from('invoice_inbox_items') - .insert({ - user_id: user.id, - status: 'processing', - source: 'upload', - document_id: document.id, - }) - .select() - .single() - - if (itemError || !inboxItem) { - return NextResponse.json({ error: 'Failed to create inbox item' }, { status: 500 }) - } - - // Emit received event - await eventBus.emit({ - type: 'supplier_invoice.received', - payload: { inboxItem, userId: user.id }, + const { data: document, error: docError } = await supabase + .from('document_attachments') + .insert({ + user_id: userId, + storage_path: storagePath, + file_name: file.name, + file_size_bytes: buffer.length, + mime_type: file.type, + sha256_hash: hash, + upload_source: 'file_upload', }) + .select() + .single() - // Process asynchronously - analyze and match - processInboxItem(inboxItem.id, user.id, base64, file.type).catch((err) => - console.error('[invoice-inbox] Background processing failed:', err) - ) - - return NextResponse.json({ data: inboxItem }) - } catch (error) { - console.error('[invoice-inbox] Upload failed:', error) - return NextResponse.json({ error: 'Upload failed' }, { status: 500 }) + if (docError || !document) { + throw new Error('Failed to create document record') } + + const { data: inboxItem, error: itemError } = await supabase + .from('invoice_inbox_items') + .insert({ + user_id: userId, + status: 'processing', + source: 'upload', + document_id: document.id, + }) + .select() + .single() + + if (itemError || !inboxItem) { + throw new Error('Failed to create inbox item') + } + + await eventBus.emit({ + type: 'supplier_invoice.received', + payload: { inboxItem, userId }, + }) + + return { inboxItem, base64 } } async function processInboxItem( @@ -137,8 +172,19 @@ async function processInboxItem( const supabase = await createClient() try { + console.log(`[invoice-inbox] Processing item=${itemId}: starting AI extraction (${mimeType})`) const extraction = await analyzeInvoice(base64, mimeType) + console.log(`[invoice-inbox] item=${itemId} extraction complete:`, { + confidence: extraction.confidence, + suggestedTemplateId: extraction.suggestedTemplateId || null, + supplier: extraction.supplier?.name || null, + total: extraction.totals?.total || null, + invoiceDate: extraction.invoice?.invoiceDate || null, + dueDate: extraction.invoice?.dueDate || null, + paymentRef: extraction.invoice?.paymentReference || null, + }) + // Supplier matching const settings = await getSettings(userId) let matchedSupplierId: string | null = null @@ -153,20 +199,31 @@ async function processInboxItem( const match = matchSupplier(extraction, suppliers) if (match && match.confidence >= settings.supplierMatchThreshold) { matchedSupplierId = match.supplierId + console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`) } } } + // Store extraction result with template suggestion + const updateData: Record = { + status: 'ready', + extracted_data: extraction as unknown as Record, + confidence: extraction.confidence, + matched_supplier_id: matchedSupplierId, + } + + if (extraction.suggestedTemplateId) { + updateData.suggested_template_id = extraction.suggestedTemplateId + updateData.suggested_template_confidence = extraction.confidence + console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`) + } + await supabase .from('invoice_inbox_items') - .update({ - status: 'ready', - extracted_data: extraction as unknown as Record, - confidence: extraction.confidence, - matched_supplier_id: matchedSupplierId, - }) + .update(updateData) .eq('id', itemId) + // Fetch the updated item for event emission and matching const { data: updatedItem } = await supabase .from('invoice_inbox_items') .select('*') @@ -182,6 +239,29 @@ async function processInboxItem( userId, }, }) + + // Document-to-transaction matching + try { + const matchResult = await matchDocumentToTransactions( + supabase, + userId, + updatedItem as InvoiceInboxItem + ) + + if (matchResult) { + await supabase + .from('invoice_inbox_items') + .update({ + matched_transaction_id: matchResult.transactionId, + match_confidence: matchResult.confidence, + match_method: matchResult.method, + }) + .eq('id', itemId) + } + } catch (matchError) { + // Non-blocking: log but don't fail the item + console.error('[invoice-inbox] Transaction matching failed:', matchError) + } } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' diff --git a/app/api/extensions/invoice-inbox/webhook/route.ts b/app/api/extensions/invoice-inbox/webhook/route.ts index 202283a9..0123f26c 100644 --- a/app/api/extensions/invoice-inbox/webhook/route.ts +++ b/app/api/extensions/invoice-inbox/webhook/route.ts @@ -4,6 +4,8 @@ import { Webhook } from 'svix' import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler' import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer' import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher' +import { classifyDocument } from '@/lib/documents/classifier' +import { processReceiptFromDocument } from '@/extensions/general/receipt-ocr/lib/receipt-pipeline' import crypto from 'crypto' function createServiceClient() { @@ -19,11 +21,31 @@ function createServiceClient() { ) } +/** + * Build raw email payload for BFL 7 kap. 2§ archiving. + * Includes full email headers and body — excludes binary attachment content. + */ +function buildRawEmailPayload(body: Record, payload: { from: string; to: string; subject: string; created_at: string }): Record { + return { + from: payload.from, + to: payload.to, + subject: payload.subject, + created_at: payload.created_at, + text: body.text ?? null, + html: body.html ?? null, + headers: body.headers ?? null, + message_id: body.message_id ?? null, + in_reply_to: body.in_reply_to ?? null, + references: body.references ?? null, + archived_at: new Date().toISOString(), + } +} + export async function POST(request: Request) { // Verify webhook signature const webhookSecret = process.env.RESEND_WEBHOOK_SECRET if (!webhookSecret) { - console.error('[invoice-inbox] RESEND_WEBHOOK_SECRET not configured') + console.error('[document-inbox] RESEND_WEBHOOK_SECRET not configured') return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 }) } @@ -61,15 +83,17 @@ export async function POST(request: Request) { const userId = await resolveUserFromEmail(payload.to, supabase) if (!userId) { - console.warn(`[invoice-inbox] No user found for email: ${payload.to}`) + console.warn(`[document-inbox] No user found for email: ${payload.to}`) return NextResponse.json({ error: 'User not found' }, { status: 404 }) } + // Build raw email payload for BFL 7:2 archiving (no binary attachment content) + const rawEmailPayload = buildRawEmailPayload(body, payload) + // Extract file attachments const attachments = extractAttachments(payload) if (attachments.length === 0) { - // Create inbox item with error status (no attachments) await supabase .from('invoice_inbox_items') .insert({ @@ -80,6 +104,7 @@ export async function POST(request: Request) { email_subject: payload.subject, email_received_at: payload.created_at, error_message: 'No supported attachments found', + raw_email_payload: rawEmailPayload, }) return NextResponse.json({ data: { processed: 0, message: 'No attachments' } }) @@ -99,7 +124,7 @@ export async function POST(request: Request) { .upload(storagePath, buffer, { contentType: attachment.content_type }) if (uploadError) { - console.error('[invoice-inbox] Upload failed:', uploadError) + console.error('[document-inbox] Upload failed:', uploadError) continue } @@ -120,7 +145,19 @@ export async function POST(request: Request) { if (docError || !document) continue - // Create inbox item + // Classify document type + let documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown' = 'supplier_invoice' + let isReverseCharge = false + try { + const classification = await classifyDocument(attachment.content, attachment.content_type) + documentType = classification.type + isReverseCharge = classification.isReverseCharge ?? false + console.log(`[document-inbox] Classified as ${documentType} (confidence: ${classification.confidence})`) + } catch (classifyErr) { + console.error('[document-inbox] Classification failed, defaulting to supplier_invoice:', classifyErr) + } + + // Create inbox item with document type and raw email payload const { data: inboxItem, error: itemError } = await supabase .from('invoice_inbox_items') .insert({ @@ -131,41 +168,103 @@ export async function POST(request: Request) { email_subject: payload.subject, email_received_at: payload.created_at, document_id: document.id, + document_type: documentType, + raw_email_payload: rawEmailPayload, }) .select() .single() if (itemError || !inboxItem) continue - // Process: analyze invoice + // Route based on document type try { - const extraction = await analyzeInvoice(attachment.content, attachment.content_type) + switch (documentType) { + case 'supplier_invoice': { + // Existing flow: analyze invoice + supplier match + const extraction = await analyzeInvoice(attachment.content, attachment.content_type) - // Supplier matching - let matchedSupplierId: string | null = null - const { data: suppliers } = await supabase - .from('suppliers') - .select('*') - .eq('user_id', userId) + // Store reverse charge flag from classifier in extracted data + const extractedData = { + ...(extraction as unknown as Record), + isReverseCharge, + } - if (suppliers && suppliers.length > 0) { - const match = matchSupplier(extraction, suppliers) - if (match && match.confidence >= 0.7) { - matchedSupplierId = match.supplierId + // Supplier matching + let matchedSupplierId: string | null = null + const { data: suppliers } = await supabase + .from('suppliers') + .select('*') + .eq('user_id', userId) + + if (suppliers && suppliers.length > 0) { + const match = matchSupplier(extraction, suppliers) + if (match && match.confidence >= 0.7) { + matchedSupplierId = match.supplierId + } + } + + await supabase + .from('invoice_inbox_items') + .update({ + status: 'ready', + extracted_data: extractedData, + confidence: extraction.confidence, + matched_supplier_id: matchedSupplierId, + }) + .eq('id', inboxItem.id) + break + } + + case 'receipt': { + // Receipt pipeline: extract + categorize + match transactions + const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath) + + const result = await processReceiptFromDocument(supabase, userId, attachment.content, attachment.content_type, { + documentId: document.id, + source: 'email', + emailFrom: payload.from, + storageUrl: urlData.publicUrl, + }) + + await supabase + .from('invoice_inbox_items') + .update({ + status: 'ready', + linked_receipt_id: result.receipt.id, + confidence: result.receipt.extraction_confidence, + }) + .eq('id', inboxItem.id) + break + } + + case 'government_letter': { + // Store with status ready for manual review + await supabase + .from('invoice_inbox_items') + .update({ + status: 'ready', + extracted_data: { + sender: payload.from, + subject: payload.subject, + body: typeof body.text === 'string' ? body.text : null, + }, + }) + .eq('id', inboxItem.id) + break + } + + case 'unknown': + default: { + // Store with status ready for manual handling + await supabase + .from('invoice_inbox_items') + .update({ status: 'ready' }) + .eq('id', inboxItem.id) + break } } - - await supabase - .from('invoice_inbox_items') - .update({ - status: 'ready', - extracted_data: extraction as unknown as Record, - confidence: extraction.confidence, - matched_supplier_id: matchedSupplierId, - }) - .eq('id', inboxItem.id) } catch (err) { - const message = err instanceof Error ? err.message : 'Analysis failed' + const message = err instanceof Error ? err.message : 'Processing failed' await supabase .from('invoice_inbox_items') .update({ status: 'error', error_message: message }) @@ -174,7 +273,7 @@ export async function POST(request: Request) { processed.push(inboxItem.id) } catch (err) { - console.error('[invoice-inbox] Processing attachment failed:', err) + console.error('[document-inbox] Processing attachment failed:', err) } } diff --git a/app/api/import/sie/execute/route.ts b/app/api/import/sie/execute/route.ts index 96985448..9de1a2d1 100644 --- a/app/api/import/sie/execute/route.ts +++ b/app/api/import/sie/execute/route.ts @@ -4,6 +4,8 @@ import { NextResponse } from 'next/server' import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser' import { suggestMappings } from '@/lib/import/account-mapper' import { executeSIEImport } from '@/lib/import/sie-import' +import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data' +import { getBASReference } from '@/lib/bookkeeping/bas-reference' import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types' /** @@ -54,24 +56,7 @@ export async function POST(request: Request) { if (mappingsJson) { mappings = JSON.parse(mappingsJson) } else { - // Fetch user's full chart of accounts (paginated to avoid 1000-row limit) - const basAccounts = await fetchAllRows(({ from, to }) => - supabase - .from('chart_of_accounts') - .select('*') - .eq('user_id', user.id) - .eq('is_active', true) - .order('account_number') - .range(from, to) - ) - - if (basAccounts.length === 0) { - return NextResponse.json({ - error: 'No chart of accounts found. Please complete onboarding first.', - }, { status: 400 }) - } - - // Load stored mappings + // Match against full BAS reference (not just user's active chart) const { data: storedMappings } = await supabase .from('sie_account_mappings') .select('*') @@ -79,7 +64,7 @@ export async function POST(request: Request) { mappings = suggestMappings( parsed.accounts, - basAccounts, + BAS_REFERENCE, (storedMappings as SIEAccountMappingRecord[]) || undefined ) } @@ -97,6 +82,94 @@ export async function POST(request: Request) { }, { status: 400 }) } + // Auto-activate any mapped BAS accounts not yet in the user's chart + const mappedAccountNumbers = [ + ...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)), + ] + + const existingAccounts = await fetchAllRows(({ from, to }) => + supabase + .from('chart_of_accounts') + .select('account_number') + .eq('user_id', user.id) + .in('account_number', mappedAccountNumbers) + .range(from, to) + ) + + // Build a lookup from SIE mappings for account names (used for bas_range accounts) + const mappingNameLookup = new Map() + for (const m of mappings) { + if (m.targetAccount) { + mappingNameLookup.set(m.targetAccount, m.targetName || m.sourceName) + } + } + + const existingNumbers = new Set(existingAccounts.map((a) => a.account_number)) + const accountsToActivate = mappedAccountNumbers + .filter((num) => !existingNumbers.has(num)) + .map((num) => { + const ref = getBASReference(num) + if (ref) { + // Account exists in BAS reference — use full metadata + return { + user_id: user.id, + account_number: ref.account_number, + account_name: ref.account_name, + account_class: ref.account_class, + account_group: ref.account_group, + account_type: ref.account_type, + normal_balance: ref.normal_balance, + plan_type: 'full_bas' as const, + is_active: true, + is_system_account: false, + description: ref.description, + sru_code: ref.sru_code, + sort_order: parseInt(ref.account_number), + } + } + + // Account not in BAS reference (sub-account like 1241 Personbilar). + // Derive metadata from the account number. + const accountClass = parseInt(num.charAt(0), 10) + const accountGroup = num.substring(0, 2) + const accountName = mappingNameLookup.get(num) || `Konto ${num}` + const accountType = + accountClass === 1 ? 'asset' + : accountClass === 2 ? 'liability' + : accountClass === 3 ? 'revenue' + : 'expense' + const normalBalance = + accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit' + + return { + user_id: user.id, + account_number: num, + account_name: accountName, + account_class: accountClass, + account_group: accountGroup, + account_type: accountType, + normal_balance: normalBalance, + plan_type: 'full_bas' as const, + is_active: true, + is_system_account: false, + description: accountName, + sru_code: null, + sort_order: parseInt(num), + } + }) + + if (accountsToActivate.length > 0) { + const { error: activateError } = await supabase + .from('chart_of_accounts') + .insert(accountsToActivate) + + if (activateError) { + return NextResponse.json({ + error: `Failed to activate accounts: ${activateError.message}`, + }, { status: 500 }) + } + } + // Execute the import const result = await executeSIEImport( user.id, diff --git a/app/api/import/sie/parse/route.ts b/app/api/import/sie/parse/route.ts index a0692b82..2b3fc37a 100644 --- a/app/api/import/sie/parse/route.ts +++ b/app/api/import/sie/parse/route.ts @@ -1,5 +1,4 @@ import { createClient } from '@/lib/supabase/server' -import { fetchAllRows } from '@/lib/supabase/fetch-all' import { NextResponse } from 'next/server' import { parseSIEFile, @@ -10,7 +9,8 @@ import { } from '@/lib/import/sie-parser' import { suggestMappings, getMappingStats } from '@/lib/import/account-mapper' import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import' -import type { SIEAccountMappingRecord, SIEAccount } from '@/lib/import/types' +import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data' +import type { SIEAccountMappingRecord } from '@/lib/import/types' /** * POST /api/import/sie/parse @@ -78,33 +78,18 @@ export async function POST(request: Request) { }, { status: 400 }) } - // Fetch user's full chart of accounts (paginated to avoid 1000-row limit) - const basAccounts = await fetchAllRows(({ from, to }) => - supabase - .from('chart_of_accounts') - .select('*') - .eq('user_id', user.id) - .eq('is_active', true) - .order('account_number') - .range(from, to) - ) - - if (basAccounts.length === 0) { - return NextResponse.json({ - error: 'No chart of accounts found. Please complete onboarding first.', - }, { status: 400 }) - } - // Fetch stored mappings from database const { data: storedMappings } = await supabase .from('sie_account_mappings') .select('*') .eq('user_id', user.id) - // Suggest account mappings + // Match against the full BAS reference (1,276 accounts) instead of only + // the user's active chart (~40 accounts). Accounts that match will be + // auto-activated during the execute step. const mappings = suggestMappings( parsed.accounts, - basAccounts, + BAS_REFERENCE, (storedMappings as SIEAccountMappingRecord[]) || undefined ) diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index f193c7bd..62d458cb 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -159,11 +159,17 @@ export async function POST( const template = getTemplateById(body.template_id) if (template) { finalCategory = is_business ? template.fallback_category : 'private' + console.log(`[categorize] tx=${id} using template="${body.template_id}" (${template.name_sv}) → category=${finalCategory}, debit=${template.debit_account}, credit=${template.credit_account}, vat=${template.vat_treatment}`) } else { return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 }) } } else { finalCategory = is_business ? (category || 'uncategorized') : 'private' + console.log(`[categorize] tx=${id} using category="${finalCategory}" vat=${body.vat_treatment || 'default'} account_override=${body.account_override || 'none'}`) + } + + if (body.inbox_item_id) { + console.log(`[categorize] tx=${id} will confirm inbox item=${body.inbox_item_id} and link document`) } // Build mapping result from template or category @@ -185,6 +191,12 @@ export async function POST( ) } + console.log(`[categorize] tx=${id} mapping result:`, { + debit: mappingResult.debit_account, + credit: mappingResult.credit_account, + vatLines: mappingResult.vat_lines.map((v) => `${v.account_number} debit=${v.debit_amount} credit=${v.credit_amount}`), + }) + // Apply account override if provided (only for business transactions) if (is_business && body.account_override) { // Validate the account exists in the user's chart of accounts @@ -287,6 +299,36 @@ export async function POST( } } + // Confirm matched inbox item and link its document to the journal entry + if (body.inbox_item_id) { + try { + await supabase + .from('invoice_inbox_items') + .update({ status: 'confirmed' }) + .eq('id', body.inbox_item_id) + .eq('user_id', user.id) + + // Link inbox item's document to the journal entry + if (journalEntryId) { + const { data: inboxItem } = await supabase + .from('invoice_inbox_items') + .select('document_id') + .eq('id', body.inbox_item_id) + .single() + + if (inboxItem?.document_id) { + await supabase + .from('document_attachments') + .update({ journal_entry_id: journalEntryId }) + .eq('id', inboxItem.document_id) + .eq('user_id', user.id) + } + } + } catch (inboxErr) { + console.error('[categorize] Failed to update inbox item:', inboxErr) + } + } + // Update the transaction const { error: updateError } = await supabase .from('transactions') diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index 0dc27903..8e67ca78 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -137,6 +137,51 @@ export async function POST(request: Request) { template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType) } + // Inject document template suggestions from matched inbox items + try { + const { data: matchedInboxItems } = await supabase + .from('invoice_inbox_items') + .select('matched_transaction_id, suggested_template_id, suggested_template_confidence') + .eq('user_id', user.id) + .in('matched_transaction_id', ids) + .not('suggested_template_id', 'is', null) + + if (matchedInboxItems && matchedInboxItems.length > 0) { + console.log(`[suggest-categories] Found ${matchedInboxItems.length} matched inbox items with template suggestions`) + const { getTemplateById } = await import('@/lib/bookkeeping/booking-templates') + + for (const item of matchedInboxItems) { + const txId = item.matched_transaction_id as string + const templateId = item.suggested_template_id as string + const template = getTemplateById(templateId) + if (!template) { + console.log(`[suggest-categories] Template "${templateId}" not found, skipping`) + continue + } + + console.log(`[suggest-categories] Injecting document template: tx=${txId} → ${templateId} (${template.name_sv}, debit=${template.debit_account}, confidence=${item.suggested_template_confidence})`) + + // Add to template_suggestions at the top with boosted confidence + const existing = template_suggestions[txId] || [] + const docTemplate: SuggestedTemplate = { + template_id: templateId, + name_sv: template.name_sv, + name_en: template.name_en, + group: template.group, + debit_account: template.debit_account, + credit_account: template.credit_account, + confidence: Math.min((item.suggested_template_confidence as number) || 0.8, 1), + description_sv: template.description_sv, + risk_level: template.risk_level, + requires_review: template.requires_review, + } + template_suggestions[txId] = [docTemplate, ...existing.filter((t) => t.template_id !== templateId)] + } + } + } catch { + // Non-blocking + } + // Trigger on-demand AI categorization for transactions with weak suggestions if (needsAiIds.length > 0) { console.log( diff --git a/components/bookkeeping/ChartOfAccountsManager.tsx b/components/bookkeeping/ChartOfAccountsManager.tsx index ff12b7b4..5c35e672 100644 --- a/components/bookkeeping/ChartOfAccountsManager.tsx +++ b/components/bookkeeping/ChartOfAccountsManager.tsx @@ -56,6 +56,7 @@ const TYPE_LABELS: Record = { equity: 'EK', revenue: 'Intakt', expense: 'Kostnad', + untaxed_reserves: 'Ob. reserver', } // --------------------------------------------------------------------------- @@ -69,6 +70,7 @@ export default function ChartOfAccountsManager() { const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts') const [searchQuery, setSearchQuery] = useState('') const [expandedClasses, setExpandedClasses] = useState>(new Set()) + const [hideK2Excluded, setHideK2Excluded] = useState(null) // Data state const [accounts, setAccounts] = useState([]) @@ -104,10 +106,25 @@ export default function ChartOfAccountsManager() { async function load() { setLoading(true) await Promise.all([fetchAccounts(), fetchReference()]) + // Set K2 filter default based on company settings (plan_type) + if (hideK2Excluded === null) { + try { + const res = await fetch('/api/settings') + if (res.ok) { + const { data } = await res.json() + // Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1') + setHideK2Excluded(data?.plan_type === 'k1') + } else { + setHideK2Excluded(false) + } + } catch { + setHideK2Excluded(false) + } + } setLoading(false) } load() - }, [fetchAccounts, fetchReference]) + }, [fetchAccounts, fetchReference, hideK2Excluded]) const refreshAll = useCallback(async () => { await Promise.all([fetchAccounts(), fetchReference()]) @@ -221,12 +238,18 @@ export default function ChartOfAccountsManager() { }, [filteredAccounts]) const filteredReference = useMemo(() => { - if (!searchQuery) return referenceAccounts - const q = searchQuery.toLowerCase() - return referenceAccounts.filter( - (a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q) - ) - }, [referenceAccounts, searchQuery]) + let filtered = referenceAccounts + if (hideK2Excluded) { + filtered = filtered.filter((a) => !a.k2_excluded) + } + if (searchQuery) { + const q = searchQuery.toLowerCase() + filtered = filtered.filter( + (a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q) + ) + } + return filtered + }, [referenceAccounts, searchQuery, hideK2Excluded]) const groupedReference = useMemo(() => { const grouped: Record = {} @@ -284,6 +307,17 @@ export default function ChartOfAccountsManager() { Eget konto )} + + {view === 'bas-catalog' && ( + + )} {/* Search */} diff --git a/components/extensions/general/DocumentInboxWorkspace.tsx b/components/extensions/general/DocumentInboxWorkspace.tsx new file mode 100644 index 00000000..47fd9e85 --- /dev/null +++ b/components/extensions/general/DocumentInboxWorkspace.tsx @@ -0,0 +1,371 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import type { InvoiceInboxItem, Supplier, DocumentClassificationType } from '@/types' +import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types' +import { PageHeader } from '@/components/ui/page-header' +import { Card, CardContent } from '@/components/ui/card' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Settings, Inbox, CheckCircle2, AlertTriangle, Receipt, FileText, RefreshCw } from 'lucide-react' +import DocumentInboxCard from '@/components/extensions/general/document-inbox/DocumentInboxCard' +import ReceiptInboxDetail from '@/components/extensions/general/document-inbox/ReceiptInboxDetail' +import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone' +import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog' +import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog' + +type TabValue = 'all' | DocumentClassificationType + +const TABS: { value: TabValue; label: string }[] = [ + { value: 'all', label: 'Alla' }, + { value: 'supplier_invoice', label: 'Fakturor' }, + { value: 'receipt', label: 'Kvitton' }, + { value: 'government_letter', label: 'Myndighetspost' }, + { value: 'unknown', label: 'Övrigt' }, +] + +const DEFAULT_SETTINGS: InvoiceInboxSettings = { + autoProcessEnabled: true, + autoMatchSupplierEnabled: true, + supplierMatchThreshold: 0.7, + inboxEmail: null, +} + +export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentProps) { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState('all') + const [selectedItem, setSelectedItem] = useState(null) + const [isUploading, setIsUploading] = useState(false) + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [settingsOpen, setSettingsOpen] = useState(false) + const [suppliers, setSuppliers] = useState([]) + + const fetchItems = useCallback(async () => { + try { + const res = await fetch('/api/extensions/invoice-inbox/inbox') + if (res.ok) { + const { data } = await res.json() + setItems(data ?? []) + } + } catch { + // Silently fail + } finally { + setLoading(false) + } + }, []) + + const fetchSettings = useCallback(async () => { + try { + const res = await fetch('/api/extensions/invoice-inbox/settings') + if (res.ok) { + const { data } = await res.json() + if (data) setSettings(data) + } + } catch { + // Use defaults + } + }, []) + + const fetchSuppliers = useCallback(async () => { + try { + const res = await fetch('/api/suppliers') + if (res.ok) { + const { data } = await res.json() + setSuppliers(data ?? []) + } + } catch { + // ok + } + }, []) + + useEffect(() => { + fetchItems() + fetchSettings() + fetchSuppliers() + }, [fetchItems, fetchSettings, fetchSuppliers]) + + function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) { + const newItems = Array.isArray(result) ? result : [result] + setItems((prev) => [...newItems, ...prev]) + for (const item of newItems) { + pollItem(item.id) + } + } + + const [isMatching, setIsMatching] = useState(false) + + async function handleMatchSweep() { + setIsMatching(true) + try { + const res = await fetch('/api/documents/match-sweep', { method: 'POST' }) + if (res.ok) { + await fetchItems() + } + } catch { + // Non-critical + } finally { + setIsMatching(false) + } + } + + async function pollItem(itemId: string) { + for (let i = 0; i < 20; i++) { + await new Promise((r) => setTimeout(r, 3000)) + try { + const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`) + if (!res.ok) continue + const { data } = await res.json() + if (data && data.status !== 'processing') { + setItems((prev) => + prev.map((it) => (it.id === itemId ? data : it)) + ) + setSelectedItem((current) => + current?.id === itemId ? data : current + ) + return + } + } catch { + // continue + } + } + } + + function handleItemClick(item: InvoiceInboxItem) { + setSelectedItem(item) + } + + async function handleConfirm(itemId: string, supplierId?: string) { + const body: Record = {} + if (supplierId) body.supplier_id = supplierId + + const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/confirm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + if (res.ok) { + setItems((prev) => + prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it)) + ) + setSelectedItem(null) + fetchSuppliers() + } + } + + async function handleReject(itemId: string) { + const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`, { + method: 'DELETE', + }) + + if (res.ok) { + setItems((prev) => + prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it)) + ) + setSelectedItem(null) + } + } + + async function handleReprocess(itemId: string) { + setItems((prev) => + prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it)) + ) + setSelectedItem(null) + + const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/process`, { + method: 'POST', + }) + + if (res.ok) { + const { data } = await res.json() + if (data) { + setItems((prev) => prev.map((it) => (it.id === itemId ? data : it))) + } + } else { + fetchItems() + } + } + + function handleReceiptConfirm() { + fetchItems() + setSelectedItem(null) + } + + async function handleSaveSettings(updated: InvoiceInboxSettings) { + const res = await fetch('/api/extensions/invoice-inbox/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updated), + }) + + if (res.ok) { + const { data } = await res.json() + if (data) setSettings(data) + } + } + + const filteredItems = + activeTab === 'all' + ? items + : items.filter((it) => (it.document_type ?? 'supplier_invoice') === activeTab) + + const totalPending = items.filter((it) => it.status === 'ready' || it.status === 'pending').length + const receiptCount = items.filter((it) => it.document_type === 'receipt' && it.status === 'ready').length + const invoiceCount = items.filter((it) => (it.document_type ?? 'supplier_invoice') === 'supplier_invoice' && it.status === 'ready').length + + // Determine which detail dialog to show + const isReceiptSelected = selectedItem?.document_type === 'receipt' + + return ( +
+ + + +
+ } + /> + + {/* KPI cards */} +
+ + +
+ +
+
+

{totalPending}

+

Att granska

+
+
+
+ + +
+ +
+
+

{invoiceCount}

+

Fakturor att granska

+
+
+
+ + +
+ +
+
+

{receiptCount}

+

Kvitton att granska

+
+
+
+
+ + {/* Upload zone */} + + + {/* Tabs by document type */} + setActiveTab(v as TabValue)}> + + {TABS.map((tab) => ( + + {tab.label} + + ))} + + + {TABS.map((tab) => ( + + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : filteredItems.length === 0 ? ( +
+ +

+ {activeTab === 'all' + ? 'Inga dokument ännu. Ladda upp ett dokument ovan eller skicka via e-post.' + : 'Inga dokument av denna typ.'} +

+
+ ) : ( +
+ {filteredItems.map((item) => ( + handleItemClick(item)} + /> + ))} +
+ )} +
+ ))} +
+ + {/* Receipt detail dialog */} + {isReceiptSelected && ( + { + if (!open) setSelectedItem(null) + }} + onConfirm={handleReceiptConfirm} + /> + )} + + {/* Invoice/other detail dialog (existing) */} + {!isReceiptSelected && ( + { + if (!open) setSelectedItem(null) + }} + onConfirm={handleConfirm} + onReject={handleReject} + onReprocess={handleReprocess} + suppliers={suppliers} + /> + )} + + {/* Settings dialog */} + + + ) +} diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index b39aa2b1..879386f7 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -87,10 +87,12 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp fetchSuppliers() }, [fetchItems, fetchSettings, fetchSuppliers]) - function handleUploadComplete(newItem: InvoiceInboxItem) { - setItems((prev) => [newItem, ...prev]) - // Poll for processing completion - pollItem(newItem.id) + function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) { + const newItems = Array.isArray(result) ? result : [result] + setItems((prev) => [...newItems, ...prev]) + for (const item of newItems) { + pollItem(item.id) + } } async function pollItem(itemId: string) { diff --git a/components/extensions/general/document-inbox/DocumentInboxCard.tsx b/components/extensions/general/document-inbox/DocumentInboxCard.tsx new file mode 100644 index 00000000..bbf31ca4 --- /dev/null +++ b/components/extensions/general/document-inbox/DocumentInboxCard.tsx @@ -0,0 +1,147 @@ +'use client' + +import type { InvoiceInboxItem, DocumentClassificationType } from '@/types' +import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + getStatusLabel, + getStatusVariant, + getConfidenceLabel, + formatExtractionSummary, + getDocumentTypeLabel, + getDocumentTypeVariant, +} from '@/lib/extensions/invoice-inbox-utils' +import { Mail, Upload, FileText, Receipt, Landmark } from 'lucide-react' + +interface DocumentInboxCardProps { + item: InvoiceInboxItem + onClick: () => void +} + +function formatRelativeTime(dateStr: string): string { + const now = new Date() + const date = new Date(dateStr) + const diffMs = now.getTime() - date.getTime() + const diffMin = Math.floor(diffMs / 60000) + if (diffMin < 1) return 'Just nu' + if (diffMin < 60) return `${diffMin} min sedan` + const diffH = Math.floor(diffMin / 60) + if (diffH < 24) return `${diffH} tim sedan` + const diffD = Math.floor(diffH / 24) + if (diffD === 1) return 'Igår' + return `${diffD} dagar sedan` +} + +function formatSEK(amount: number): string { + return new Intl.NumberFormat('sv-SE', { + style: 'currency', + currency: 'SEK', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount) +} + +function getDocumentIcon(type: DocumentClassificationType) { + switch (type) { + case 'receipt': + return + case 'government_letter': + return + default: + return + } +} + +function getSummaryText(item: InvoiceInboxItem): { label: string; total: number } { + const type = item.document_type ?? 'supplier_invoice' + + switch (type) { + case 'supplier_invoice': { + const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null + const summary = formatExtractionSummary(extraction) + return { + label: (item.supplier as { name?: string } | undefined)?.name ?? (summary.supplierName || 'Okänd leverantör'), + total: summary.total, + } + } + case 'receipt': { + const receipt = item.receipt as { merchant_name?: string; total_amount?: number } | undefined + return { + label: receipt?.merchant_name ?? 'Okänd handlare', + total: receipt?.total_amount ?? 0, + } + } + case 'government_letter': { + return { + label: item.email_from ?? 'Okänd avsändare', + total: 0, + } + } + default: { + return { label: 'Granska manuellt', total: 0 } + } + } +} + +export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardProps) { + const confidence = getConfidenceLabel(item.confidence) + const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' + const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' + const docType = (item.document_type ?? 'supplier_invoice') as DocumentClassificationType + const docTypeVariant = getDocumentTypeVariant(docType) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' + + const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil' + const { label: summaryLabel, total } = getSummaryText(item) + + return ( + + +
+ {item.source === 'email' ? ( + + ) : ( + + )} +
+ +
+
+ {getDocumentIcon(docType)} + {fileName} +
+
+ + {summaryLabel} + +
+
+ +
+ {total > 0 && ( + {formatSEK(total)} + )} +
+ + {getDocumentTypeLabel(docType)} + + {item.confidence != null && ( + + {confidence.label} + + )} + + {getStatusLabel(item.status)} + +
+ + {formatRelativeTime(item.created_at)} + +
+
+
+ ) +} diff --git a/components/extensions/general/document-inbox/ReceiptInboxDetail.tsx b/components/extensions/general/document-inbox/ReceiptInboxDetail.tsx new file mode 100644 index 00000000..3c883518 --- /dev/null +++ b/components/extensions/general/document-inbox/ReceiptInboxDetail.tsx @@ -0,0 +1,311 @@ +'use client' + +import { useState } from 'react' +import type { InvoiceInboxItem } from '@/types' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { Switch } from '@/components/ui/switch' +import { CheckCircle2, Receipt, LinkIcon } from 'lucide-react' + +interface ReceiptLineItem { + id: string + description: string + line_total: number + vat_rate: number | null + is_business: boolean | null + category: string | null + bas_account: string | null +} + +interface ReceiptInboxDetailProps { + item: InvoiceInboxItem | null + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void +} + +function formatSEK(amount: number): string { + return new Intl.NumberFormat('sv-SE', { + style: 'currency', + currency: 'SEK', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount) +} + +export default function ReceiptInboxDetail({ + item, + open, + onOpenChange, + onConfirm, +}: ReceiptInboxDetailProps) { + const [lineItems, setLineItems] = useState([]) + const [loading, setLoading] = useState(false) + const [confirming, setConfirming] = useState(false) + const [representationPersons, setRepresentationPersons] = useState(null) + const [representationPurpose, setRepresentationPurpose] = useState('') + const [representationBusinessConnection, setRepresentationBusinessConnection] = useState('') + + const receipt = item?.receipt as { + id?: string + merchant_name?: string + total_amount?: number + receipt_date?: string + status?: string + matched_transaction_id?: string + } | undefined + + // Fetch line items when dialog opens + async function fetchLineItems() { + if (!item?.linked_receipt_id) return + setLoading(true) + try { + const res = await fetch(`/api/extensions/receipt-ocr/${item.linked_receipt_id}`) + if (res.ok) { + const { data } = await res.json() + if (data?.line_items) { + setLineItems(data.line_items) + } + } + } catch { + // ok + } finally { + setLoading(false) + } + } + + function handleOpenChange(isOpen: boolean) { + if (isOpen && item?.linked_receipt_id) { + fetchLineItems() + } + onOpenChange(isOpen) + } + + function toggleBusiness(lineItemId: string) { + setLineItems((prev) => + prev.map((li) => + li.id === lineItemId + ? { ...li, is_business: li.is_business === true ? false : true } + : li + ) + ) + } + + async function handleConfirm() { + if (!item?.id || !item.linked_receipt_id) return + setConfirming(true) + + try { + const body: Record = { + line_items: lineItems.map((li) => ({ + id: li.id, + is_business: li.is_business, + category: li.category, + bas_account: li.bas_account, + })), + } + + if (receipt?.matched_transaction_id) { + body.matched_transaction_id = receipt.matched_transaction_id + } + if (representationPersons != null && representationPersons > 0) { + body.representation_persons = representationPersons + } + if (representationPurpose) { + body.representation_purpose = representationPurpose + } + if (representationBusinessConnection) { + body.representation_business_connection = representationBusinessConnection + } + + const res = await fetch( + `/api/extensions/invoice-inbox/inbox/${item.id}/confirm-receipt`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ) + + if (res.ok) { + onConfirm() + onOpenChange(false) + } + } catch { + // ok + } finally { + setConfirming(false) + } + } + + const businessTotal = lineItems + .filter((li) => li.is_business === true) + .reduce((sum, li) => sum + li.line_total, 0) + const privateTotal = lineItems + .filter((li) => li.is_business === false) + .reduce((sum, li) => sum + li.line_total, 0) + + return ( + + + + + + Kvitto via e-post + + + + {receipt && ( +
+ {/* Summary */} +
+
+ Handlare +

{receipt.merchant_name ?? 'Okänd'}

+
+
+ Datum +

{receipt.receipt_date ?? '-'}

+
+
+ Totalbelopp +

+ {receipt.total_amount ? formatSEK(receipt.total_amount) : '-'} +

+
+
+ Transaktionsmatch +

+ {receipt.matched_transaction_id ? ( + <> + + Matchad + + ) : ( + Ingen match + )} +

+
+
+ + + + {/* Line items with business/private toggle */} +
+ + {loading ? ( +

Laddar...

+ ) : lineItems.length === 0 ? ( +

Inga rader extraherade

+ ) : ( +
+ {lineItems.map((li) => ( +
+
+

{li.description}

+

+ {formatSEK(li.line_total)} + {li.vat_rate != null && ` (${li.vat_rate}% moms)`} +

+
+
+ Företag + toggleBusiness(li.id)} + /> +
+
+ ))} +
+ )} +
+ + {/* Totals */} + {lineItems.length > 0 && ( +
+ Företag: {formatSEK(Math.round(businessTotal * 100) / 100)} + Privat: {formatSEK(Math.round(privateTotal * 100) / 100)} +
+ )} + + + + {/* Representation fields */} +
+ +
+
+ + + setRepresentationPersons( + e.target.value ? parseInt(e.target.value) : null + ) + } + placeholder="0" + /> +
+
+ + setRepresentationPurpose(e.target.value)} + placeholder="T.ex. kundmöte" + /> +
+
+
+ + + setRepresentationBusinessConnection(e.target.value) + } + placeholder="T.ex. potentiell kund, pågående projekt" + /> +
+
+
+ )} + + + + + +
+
+ ) +} diff --git a/components/extensions/general/invoice-inbox/InboxUploadZone.tsx b/components/extensions/general/invoice-inbox/InboxUploadZone.tsx index dafd37af..15ca5343 100644 --- a/components/extensions/general/invoice-inbox/InboxUploadZone.tsx +++ b/components/extensions/general/invoice-inbox/InboxUploadZone.tsx @@ -2,15 +2,21 @@ import { useCallback, useRef, useState } from 'react' import type { InvoiceInboxItem } from '@/types' -import { Upload, Loader2, FileUp } from 'lucide-react' +import { Upload, Loader2, FileUp, CheckCircle2, AlertCircle } from 'lucide-react' import { cn } from '@/lib/utils' interface InboxUploadZoneProps { - onUploadComplete: (item: InvoiceInboxItem) => void + onUploadComplete: (item: InvoiceInboxItem | InvoiceInboxItem[]) => void isUploading: boolean setIsUploading: (v: boolean) => void } +interface FileProgress { + name: string + status: 'pending' | 'uploading' | 'done' | 'error' + error?: string +} + const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] const MAX_SIZE = 10 * 1024 * 1024 // 10 MB @@ -21,27 +27,48 @@ export default function InboxUploadZone({ }: InboxUploadZoneProps) { const [isDragOver, setIsDragOver] = useState(false) const [error, setError] = useState(null) + const [fileProgress, setFileProgress] = useState([]) const inputRef = useRef(null) - const uploadFile = useCallback( - async (file: File) => { + const uploadFiles = useCallback( + async (files: File[]) => { setError(null) - if (!ACCEPTED_TYPES.includes(file.type)) { - setError('Filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.') - return + // Validate all files first + const validFiles: File[] = [] + for (const file of files) { + if (!ACCEPTED_TYPES.includes(file.type)) { + setError(`${file.name}: filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.`) + return + } + if (file.size > MAX_SIZE) { + setError(`${file.name}: filen är för stor. Max 10 MB.`) + return + } + validFiles.push(file) } - if (file.size > MAX_SIZE) { - setError('Filen är för stor. Max 10 MB.') - return - } + if (validFiles.length === 0) return setIsUploading(true) + // Show per-file progress for multi-file uploads + if (validFiles.length > 1) { + setFileProgress(validFiles.map((f) => ({ name: f.name, status: 'uploading' }))) + } + try { const formData = new FormData() - formData.append('file', file) + + if (validFiles.length === 1) { + // Single file: use legacy `file` key for backward compat + formData.append('file', validFiles[0]) + } else { + // Multiple files: use `files` key + for (const file of validFiles) { + formData.append('files', file) + } + } const res = await fetch('/api/extensions/invoice-inbox/inbox', { method: 'POST', @@ -51,13 +78,43 @@ export default function InboxUploadZone({ if (!res.ok) { const body = await res.json().catch(() => ({ error: 'Uppladdning misslyckades' })) setError(body.error ?? 'Uppladdning misslyckades') + if (validFiles.length > 1) { + setFileProgress((prev) => prev.map((f) => ({ ...f, status: 'error' as const }))) + } return } - const { data } = await res.json() - onUploadComplete(data) + const body = await res.json() + + if (validFiles.length === 1) { + onUploadComplete(body.data) + setFileProgress([]) + } else { + // Mark individual files + const items: InvoiceInboxItem[] = body.data || [] + const errors: string[] = body.errors || [] + + setFileProgress((prev) => + prev.map((fp, i) => { + // Check if this file had an error + const errMsg = errors.find((e) => e.startsWith(fp.name)) + if (errMsg) { + return { ...fp, status: 'error' as const, error: errMsg } + } + return { ...fp, status: 'done' as const } + }) + ) + + if (items.length > 0) { + onUploadComplete(items) + } + + // Clear progress after a delay + setTimeout(() => setFileProgress([]), 3000) + } } catch { setError('Nätverksfel vid uppladdning') + setFileProgress([]) } finally { setIsUploading(false) } @@ -69,10 +126,10 @@ export default function InboxUploadZone({ (e: React.DragEvent) => { e.preventDefault() setIsDragOver(false) - const file = e.dataTransfer.files[0] - if (file) uploadFile(file) + const files = Array.from(e.dataTransfer.files) + if (files.length > 0) uploadFiles(files) }, - [uploadFile] + [uploadFiles] ) const handleDragOver = useCallback((e: React.DragEvent) => { @@ -87,12 +144,11 @@ export default function InboxUploadZone({ const handleFileSelect = useCallback( (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) uploadFile(file) - // Reset so same file can be re-selected + const files = Array.from(e.target.files || []) + if (files.length > 0) uploadFiles(files) e.target.value = '' }, - [uploadFile] + [uploadFiles] ) return ( @@ -114,6 +170,7 @@ export default function InboxUploadZone({ ref={inputRef} type="file" accept=".pdf,.jpg,.jpeg,.png,.webp" + multiple className="hidden" onChange={handleFileSelect} disabled={isUploading} @@ -127,22 +184,40 @@ export default function InboxUploadZone({ ) : isDragOver ? ( <> -

Släpp filen här

+

Släpp filerna här

) : ( <>

- Dra och släpp en faktura, eller{' '} - välj fil + Dra och släpp fakturor, eller{' '} + välj filer

- PDF, JPEG, PNG eller WebP (max 10 MB) + PDF, JPEG, PNG eller WebP (max 10 MB per fil)

)} + {fileProgress.length > 0 && ( +
+ {fileProgress.map((fp) => ( +
+ {fp.status === 'uploading' && } + {fp.status === 'done' && } + {fp.status === 'error' && } + + {fp.name} + +
+ ))} +
+ )} + {error && (

{error}

)} diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx index cf227023..2c98c389 100644 --- a/components/transactions/SwipeCategorizationView.tsx +++ b/components/transactions/SwipeCategorizationView.tsx @@ -10,6 +10,7 @@ import VatTreatmentSelect from './VatTreatmentSelect' import { formatCurrency, formatDate } from '@/lib/utils' import { checkExpenseWarnings } from '@/lib/tax/expense-warnings' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' +import { getTemplateById } from '@/lib/bookkeeping/booking-templates' import JournalEntryPreview from './JournalEntryPreview' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' @@ -59,6 +60,8 @@ export default function SwipeCategorizationView({ const [showUploadZone, setShowUploadZone] = useState(false) const [showDescribeDialog, setShowDescribeDialog] = useState(false) const [showVatDropdown, setShowVatDropdown] = useState(false) + const [pendingTemplateId, setPendingTemplateId] = useState(null) + const [pendingInboxItemId, setPendingInboxItemId] = useState(null) // Clear VAT treatment when switching to a liability/equity account (class 2) useEffect(() => { @@ -120,6 +123,23 @@ export default function SwipeCategorizationView({ setPendingCategory(category) setAccountOverride(defaultAccount) setVatTreatment(defaultVat ?? 'none') + setPendingTemplateId(null) + setPendingInboxItemId(null) + setShowVatDropdown(false) + setShowCategorySelect(false) + setShowReviewStep(true) + setError(null) + }, []) + + const handleTemplateSelect = useCallback((templateId: string, inboxItemId?: string) => { + const template = getTemplateById(templateId) + if (!template) return + + setPendingCategory(template.fallback_category) + setAccountOverride(template.debit_account) + setVatTreatment(template.vat_treatment ?? 'none') + setPendingTemplateId(templateId) + setPendingInboxItemId(inboxItemId ?? null) setShowVatDropdown(false) setShowCategorySelect(false) setShowReviewStep(true) @@ -180,7 +200,9 @@ export default function SwipeCategorizationView({ true, pendingCategory, resolvedVat, - override + override, + pendingTemplateId ?? undefined, + pendingInboxItemId ?? undefined ) if (journalEntryId) { // Link uploaded documents to the journal entry @@ -210,6 +232,8 @@ export default function SwipeCategorizationView({ resetUploadState() setShowReviewStep(false) setPendingCategory(null) + setPendingTemplateId(null) + setPendingInboxItemId(null) moveToNext() } else { setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.') @@ -248,6 +272,8 @@ export default function SwipeCategorizationView({ setShowCategorySelect(false) setShowReviewStep(false) setPendingCategory(null) + setPendingTemplateId(null) + setPendingInboxItemId(null) resetUploadState() moveToNext() }, [moveToNext, resetUploadState]) @@ -424,38 +450,51 @@ export default function SwipeCategorizationView({ - {/* Document upload */} -
-
- {showUploadZone ? ( - - ) : ( - + + {showUploadZone && ( +
+ +
)} - - {showUploadZone && ( -
- -
- )} - + + )} {error && (
@@ -603,6 +642,55 @@ export default function SwipeCategorizationView({
)} + {/* Document Match from Inbox */} + {currentTransaction.matched_inbox_item && ( +
+
+
+ + + {currentTransaction.matched_inbox_item.document_type === 'receipt' + ? 'Matchat kvitto' + : currentTransaction.matched_inbox_item.document_type === 'supplier_invoice' + ? 'Matchad leverantörsfaktura' + : 'Matchat dokument'} + +
+ {currentTransaction.matched_inbox_item.match_confidence != null && ( + + {Math.round(currentTransaction.matched_inbox_item.match_confidence * 100)}% + + )} +
+
+ {(() => { + const ext = currentTransaction.matched_inbox_item.extracted_data as Record | null + if (!ext) return null + const supplierName = (ext as { supplier?: { name?: string } })?.supplier?.name + const merchantName = (ext as { merchant?: { name?: string } })?.merchant?.name + const totals = ext as { totals?: { total?: number } } + return ( + <> + {(supplierName || merchantName) && ( +

{supplierName || merchantName}

+ )} + {totals?.totals?.total != null && ( +

+ {formatCurrency(totals.totals.total)} +

+ )} + + ) + })()} + {currentTransaction.matched_inbox_item.suggested_template_id && ( +

+ Mall: {currentTransaction.matched_inbox_item.suggested_template_id} +

+ )} +
+
+ )} + {/* Warnings */} {warnings.length > 0 && (
@@ -642,6 +730,23 @@ export default function SwipeCategorizationView({
)} + {/* Document template match — primary action when inbox item has a suggested template */} + {currentTransaction.matched_inbox_item?.suggested_template_id && (() => { + const tmplId = currentTransaction.matched_inbox_item!.suggested_template_id! + const template = getTemplateById(tmplId) + if (!template) return null + return ( + + ) + })()} + {/* Invoice match button - primary action when there's a match */} {currentTransaction.potential_invoice && onMatchInvoice && ( + ) : hasSupplierInvoiceMatch ? ( + ) : topSuggestion ? (