'use client' import { useState } from 'react' import { useTranslations } from 'next-intl' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { ArrowUpRight, ArrowDownRight, ChevronDown, ChevronUp, FileText, Inbox, Paperclip, X } from 'lucide-react' import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm' 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 } from '@/types' import type { TransactionWithInvoice } from './transaction-types' interface TransactionBookingDialogProps { open: boolean onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null onBooked: ( transactionId: string, journalEntryId: string, attachedDocumentId?: string | null, ) => void preselectedTemplate?: BookingTemplateLibrary | null } function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescription: string): 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: '1930', 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, ): 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) // Match buildInitialLines's foreign-currency handling: attach original // currency/amount/exchange_rate metadata to the settlement (bank/cash) legs // so the journal entry retains the foreign-currency annotation. Without // this the entry is silently recorded in SEK only. const isForeign = !!transaction.currency && transaction.currency !== 'SEK' if (!isForeign) return lines const currencyMeta = buildCurrencyMetadata( transaction.currency, Math.abs(transaction.amount), transaction.exchange_rate ) return lines.map((line, i) => { const raw = template.lines[i] return raw?.type === 'settlement' ? { ...line, ...currencyMeta } : 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 [showUploadZone, setShowUploadZone] = useState(false) const [inboxPickerOpen, setInboxPickerOpen] = useState(false) if (!transaction) return null const isIncome = transaction.amount > 0 const handleBooked = async (transactionId: string, journalEntryId: string) => { // 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([]) setShowUploadZone(false) onBooked(transactionId, journalEntryId, pinnedDocId) } const attachedCount = uploadedFiles.filter((f) => f.status === 'uploaded').length + pickedInboxDocs.length return ( { if (!o) { setUploadedFiles([]) setPickedInboxDocs([]) setShowUploadZone(false) setInboxPickerOpen(false) } onOpenChange(o) }}> {t('title')} {t('description')} {/* Transaction summary */}
{isIncome ? ( ) : ( )}

{transaction.description}

{formatDate(transaction.date)}

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

{/* Document upload section */}
{showUploadZone && (
{pickedInboxDocs.length > 0 && (
{pickedInboxDocs.map((doc) => (
{doc.supplier_name ?? doc.file_name} {doc.amount != null && ( {formatCurrency(doc.amount, doc.currency ?? 'SEK')} )}
))}
)}
)}
handleBooked(transaction.id, entryId)} /> setInboxPickerOpen(false)} onSelect={(doc) => setPickedInboxDocs((prev) => prev.some((d) => d.document_id === doc.document_id) ? prev : [...prev, doc], ) } />
) }