'use client' import { useState, useEffect } from 'react' import { useTranslations } from 'next-intl' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { ArrowUpRight, ArrowDownRight, FileText, Inbox, X } from 'lucide-react' import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm' import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker' import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPicker' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils' import { applyTemplate } from '@/lib/bookkeeping/template-library' import type { BookingTemplateLibrary, CashAccount } from '@/types' import type { TransactionWithInvoice } from './transaction-types' import { resolveAccount } from '@/lib/cash-accounts/resolve-account' interface TransactionBookingDialogProps { open: boolean onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null onBooked: ( transactionId: string, journalEntryId: string, attachedDocumentId?: string | null, /** True when the transaction was LINKED to an existing voucher via the * duplicate guard's match action: no new verifikat was created. */ matched?: boolean, ) => void preselectedTemplate?: BookingTemplateLibrary | null } function buildInitialLines( transaction: TransactionWithInvoice, bankLineDescription: string, bankAccount: string = '1930', ): FormLine[] { const sekAmount = Math.round(Math.abs(resolveSekAmount( transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate )) * 100) / 100 const amountStr = sekAmount.toFixed(2) const isExpense = transaction.amount < 0 const isForeign = !!transaction.currency && transaction.currency !== 'SEK' const currencyMeta = isForeign ? buildCurrencyMetadata( transaction.currency, Math.abs(transaction.amount), transaction.exchange_rate ) : {} const bankLine: FormLine = { account_number: bankAccount, debit_amount: isExpense ? '' : amountStr, credit_amount: isExpense ? amountStr : '', line_description: bankLineDescription, ...currencyMeta, } const counterLine: FormLine = { account_number: '', debit_amount: isExpense ? amountStr : '', credit_amount: isExpense ? '' : amountStr, line_description: '', } return isExpense ? [bankLine, counterLine] : [bankLine, counterLine] } function buildInitialLinesFromTemplate( transaction: TransactionWithInvoice, template: BookingTemplateLibrary, bankAccount: string = '1930', ): FormLine[] { const sekAmount = Math.round(Math.abs(resolveSekAmount( transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate )) * 100) / 100 const lines = applyTemplate(template.lines, sekAmount) const isForeign = !!transaction.currency && transaction.currency !== 'SEK' const currencyMeta = isForeign ? buildCurrencyMetadata( transaction.currency, Math.abs(transaction.amount), transaction.exchange_rate ) : {} return lines.map((line, i) => { const raw = template.lines[i] if (raw?.type === 'settlement') { return { ...line, ...(isForeign ? currencyMeta : {}), account_number: bankAccount } } return line }) } export default function TransactionBookingDialog({ open, onOpenChange, transaction, onBooked, preselectedTemplate, }: TransactionBookingDialogProps) { const t = useTranslations('tx_booking_dialog') const { toast } = useToast() const [uploadedFiles, setUploadedFiles] = useState([]) const [pickedInboxDocs, setPickedInboxDocs] = useState([]) const [inboxPickerOpen, setInboxPickerOpen] = useState(false) const [bankAccount, setBankAccount] = useState(null) const [bankAccountName, setBankAccountName] = useState(null) useEffect(() => { if (!open || !transaction) return setBankAccount(null) setBankAccountName(null) let cancelled = false fetch('/api/cash-accounts') .then((r) => { if (!r.ok) throw new Error(`cash-accounts fetch failed: ${r.status}`) return r.json() }) .then((json) => { if (cancelled) return const accounts = (json.data ?? []) as CashAccount[] const { account } = resolveAccount( accounts, transaction.cash_account_id ?? null, transaction.currency ?? 'SEK', ) // Use the matched account's own name instead of a generic label. const matched = accounts.find((a) => a.id === transaction.cash_account_id) ?? accounts.find((a) => a.ledger_account === account) setBankAccount(account) setBankAccountName(matched?.name ?? null) }) .catch(() => { if (!cancelled) setBankAccount('1930') }) return () => { cancelled = true } }, [open, transaction?.id]) if (!transaction) return null const isIncome = transaction.amount > 0 const handleBooked = async (transactionId: string, journalEntryId: string, matched = false) => { // Link any attached documents to the new journal entry: freshly uploaded // files, and existing inbox documents picked via InboxDocumentPicker. For // picked docs, inbox_item_id stamps the inbox item as consumed so it drops // out of the active inbox: see app/api/documents/[id]/link/route.ts. // transaction_id additionally pins the doc to the transaction row so the // /transactions list shows the underlag indicator (first linked doc wins). const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) let linkFailCount = 0 let firstLinkedDocId: string | null = null for (const file of filesToLink) { try { const res = await fetch(`/api/documents/${file.id}/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId, transaction_id: transactionId, }), }) if (!res.ok) linkFailCount++ else firstLinkedDocId ??= file.id ?? null } catch { linkFailCount++ } } for (const doc of pickedInboxDocs) { try { const res = await fetch(`/api/documents/${doc.document_id}/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId, inbox_item_id: doc.inbox_item_id, transaction_id: transactionId, }), }) if (!res.ok) linkFailCount++ else firstLinkedDocId ??= doc.document_id } catch { linkFailCount++ } } if (linkFailCount > 0) { toast({ title: t('doc_link_failed_title'), description: t('doc_link_failed_description', { count: linkFailCount }), variant: 'destructive', }) } // The server pins only when the tx has no document_id yet (first linked // doc wins): mirror that here so the optimistic state never claims a // pin the server refused to swap. const pinnedDocId = transaction.document_id ? null : firstLinkedDocId if (pinnedDocId) { // Same event AgentChat dispatches after uploads: flips the inbox card's // paperclip optimistically without a refetch. window.dispatchEvent( new CustomEvent('Accounted:transaction-document-linked', { detail: { transaction_id: transactionId, document_id: pinnedDocId }, }), ) } setUploadedFiles([]) setPickedInboxDocs([]) onBooked(transactionId, journalEntryId, pinnedDocId, matched) } // The receipt to show beside the form. A transaction may arrive with a // pre-linked document; otherwise the user attaches one in-dialog (upload or // inbox pick) and it appears here as soon as it's available. const uploadedDoc = uploadedFiles.find((f) => f.status === 'uploaded' && f.id) const pickedDoc = pickedInboxDocs[0] const preexistingDocId = transaction.document_id ?? null const inDialogDocId = uploadedDoc?.id ?? pickedDoc?.document_id ?? null const currentDocId = preexistingDocId ?? inDialogDocId const currentDocMime = preexistingDocId ? null : uploadedDoc?.file.type ?? null const currentDocName = preexistingDocId ? null : uploadedDoc?.fileName ?? pickedDoc?.file_name ?? null return ( { if (!o) { setUploadedFiles([]) setPickedInboxDocs([]) setInboxPickerOpen(false) } onOpenChange(o) }}> {t('title')} {t('description')} {/* Transaction summary */}
{isIncome ? ( ) : ( )}

{transaction.description}

{formatDate(transaction.date)}

{isIncome ? '+' : ''} {formatCurrency(transaction.amount, transaction.currency)}

{/* Document column: sticky on desktop so the receipt stays visible while the form scrolls; stacks above the form on smaller screens. */}
{currentDocId ? ( ) : (
)} {/* Attach controls: only when the transaction has no pre-linked document (a pre-linked one is already the verifikat's underlag). */} {!preexistingDocId && (
{pickedInboxDocs.length > 0 && (
{pickedInboxDocs.map((doc) => (
{doc.supplier_name ?? doc.file_name}
))}
)}
{inDialogDocId && ( )}
)}
{/* Booking form */}
{bankAccount !== null && ( handleBooked(transaction.id, entryId)} duplicateMatchTransaction={{ id: transaction.id, cash_account_id: transaction.cash_account_id ?? null, currency: transaction.currency ?? 'SEK', }} // Duplicate guard match: the bank line was linked to an existing // voucher (no new entry). Reuse the booked flow so attached // documents land on that verifikat and the row leaves the list. onDuplicateMatched={(journalEntryId) => handleBooked(transaction.id, journalEntryId, true)} /> )}
setInboxPickerOpen(false)} onSelect={(doc) => setPickedInboxDocs((prev) => prev.some((d) => d.document_id === doc.document_id) ? prev : [...prev, doc], ) } />
) }