'use client' import { useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogVeil, useDashShellInert } 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 { proposalLinesToFormLines } from '@/lib/bookkeeping/proposal-lines' import type { ProposalLine } from '@/lib/bookkeeping/proposal-lines' import type { BookingTemplateLibrary } from '@/types' import type { TransactionWithInvoice } from './transaction-types' import { resolveAccount } from '@/lib/cash-accounts/resolve-account' import { useCashAccounts } from '@/lib/reference-data/hooks' 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 /** * "Andra rader" hand-off from a proposal view (QuickReviewDialog): the * COMPUTED lines the user was shown, prefilled for per-line editing. Takes * precedence over preselectedTemplate. The settlement leg's account is * swapped for the transaction's resolved cash account, same as the * library-template path. */ proposalLines?: ProposalLine[] | null /** Account number (string, e.g. '5460') to prefill on the counter line: * set when the user picked an account from the template picker's "Konton" * search results. Ignored when a preselectedTemplate is present. */ preselectedAccount?: string | null } function buildInitialLines( transaction: TransactionWithInvoice, bankLineDescription: string, bankAccount: string = '1930', counterAccount?: string | null, ): 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: counterAccount ?? '', 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, proposalLines, preselectedAccount, }: TransactionBookingDialogProps) { const t = useTranslations('tx_booking_dialog') const { toast } = useToast() const [uploadedFiles, setUploadedFiles] = useState([]) const [pickedInboxDocs, setPickedInboxDocs] = useState([]) const [inboxPickerOpen, setInboxPickerOpen] = useState(false) // Settlement account for the bank line: resolved from the session-cached // cash accounts (seeded by the dashboard layout), so the form below mounts // on the first paint instead of after a /api/cash-accounts round trip on // every open. null only while the list is genuinely still loading. const { cashAccounts, isLoading: cashAccountsLoading } = useCashAccounts() const { bankAccount, bankAccountName } = useMemo(() => { if (!transaction || cashAccountsLoading) { return { bankAccount: null as string | null, bankAccountName: null as string | null } } const { account } = resolveAccount( cashAccounts, transaction.cash_account_id ?? null, transaction.currency ?? 'SEK', ) // Use the matched account's own name instead of a generic label. const matched = cashAccounts.find((a) => a.id === transaction.cash_account_id) ?? cashAccounts.find((a) => a.ledger_account === account) return { bankAccount: account, bankAccountName: matched?.name ?? null } }, [transaction, cashAccounts, cashAccountsLoading]) // Non-modal dialog (see below): page modality is restored by hand so the // agent sheet stays live. See useDashShellInert in components/ui/dialog.tsx. useDashShellInert(open) 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) }} modal={false}> e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} > {t('title')} {t('description')} {/* Transaction summary */}
{isIncome ? ( ) : ( )}

{transaction.description}

{formatDate(transaction.date)}

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

{/* Underlag column: one coherent surface. Empty state sizes to its content (dropzone + attached inbox-picker row) and top-aligns; once a document exists the column takes the fixed viewer height and stays sticky on desktop 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 && ( currentDocId ? (
{pickedInboxDocs.length > 0 && (
{pickedInboxDocs.map((doc) => (
{doc.supplier_name ?? doc.file_name}
))}
)}
) : ( /* Secondary intake path, rendered as the dropzone's footer so "drop a file" and "pick from the inbox" read as one surface. */ ) )}
{/* Booking form */}
{bankAccount !== null && ( 0 ? 'proposal' : preselectedTemplate?.id ?? 'default'}-${preselectedAccount ?? 'none'}-${bankAccount}`} embedded initialLines={ proposalLines && proposalLines.length > 0 ? proposalLinesToFormLines(proposalLines, { settlementAccount: bankAccount, currency: transaction.currency, foreignAmount: Math.abs(transaction.amount), exchangeRate: transaction.exchange_rate, }) : preselectedTemplate ? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount) : buildInitialLines(transaction, bankAccountName ?? t('bank_line_description'), bankAccount, preselectedAccount) } initialDate={transaction.date} initialDescription={transaction.description} submitUrl={`/api/transactions/${transaction.id}/book`} sourceType="bank_transaction" sourceId={transaction.id} // Series picker seeded from the bank account's own series // (Inställningar → Bokföring → Verifikationsserie per bankkonto). seriesPicker cashAccountId={transaction.cash_account_id ?? null} onEntryCreated={(entryId) => 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], ) } />
) }