'use client' import { useState, useEffect, useCallback } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react' import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping' import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import JournalEntryPreview from './JournalEntryPreview' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import VatTreatmentSelect from './VatTreatmentSelect' import { VAT_TREATMENT_OPTIONS } from './transaction-types' import type { TransactionWithInvoice } from './transaction-types' import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types' interface QuickReviewDialogProps { open: boolean onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null category: TransactionCategory | null categoryLabel: string defaultAccount: string defaultVat: VatTreatment | 'none' entityType?: EntityType template?: BookingTemplate | null templateId?: string counterpartyLinePattern?: LinePatternEntry[] | null onConfirm: ( id: string, category: TransactionCategory, vatTreatment: VatTreatment | undefined, accountOverride: string | undefined, templateId?: string ) => Promise onChangeTemplate?: () => void } export default function QuickReviewDialog({ open, onOpenChange, transaction, category, categoryLabel, defaultAccount, defaultVat, entityType, template, templateId, counterpartyLinePattern, onConfirm, onChangeTemplate, }: QuickReviewDialogProps) { const t = useTranslations('tx_quick_review') const tCat = useTranslations('tx_categories') const { toast } = useToast() const [accountOverride, setAccountOverride] = useState(defaultAccount) const [vatTreatment, setVatTreatment] = useState(defaultVat) const [accounts, setAccounts] = useState([]) const [isProcessing, setIsProcessing] = useState(false) const [error, setError] = useState(null) const [uploadedFiles, setUploadedFiles] = useState([]) const [showUploadZone, setShowUploadZone] = useState(false) const [showVatDropdown, setShowVatDropdown] = useState(false) const [isOpeningDoc, setIsOpeningDoc] = useState(false) // Mirror of `transaction` so we can patch in a freshly-fetched SEK conversion // before the user confirms — the verifikation must always be in SEK and the // engine reads these fields straight off the transaction row. const [enrichedTx, setEnrichedTx] = useState(transaction) const [rateLoading, setRateLoading] = useState(false) const [rateError, setRateError] = useState(null) const preAttachedDocumentId = transaction?.document_id ?? null const handleOpenAttachedDoc = useCallback(async () => { if (!preAttachedDocumentId || isOpeningDoc) return setIsOpeningDoc(true) try { const res = await fetch(`/api/documents/${preAttachedDocumentId}`) if (!res.ok) { toast({ title: t('open_attached_failed'), variant: 'destructive' }) return } const { data } = await res.json() if (data?.download_url) { window.open(data.download_url, '_blank', 'noopener,noreferrer') } } finally { setIsOpeningDoc(false) } }, [preAttachedDocumentId, isOpeningDoc, toast, t]) // Handle account changes — clear VAT for liability/equity accounts (class 2) const handleAccountChange = useCallback((account: string) => { setAccountOverride(account) if (account.startsWith('2')) { setVatTreatment('none') } }, []) // Fetch accounts on mount useEffect(() => { async function fetchAccounts() { try { const res = await fetch('/api/bookkeeping/accounts') const { data } = await res.json() if (data) { setAccounts(data) } } catch { // Non-critical } } fetchAccounts() }, []) // Reset local mirror whenever the underlying transaction changes (the parent // reuses the dialog instance across rows). useEffect(() => { setEnrichedTx(transaction) setRateError(null) }, [transaction]) // Backfill the SEK conversion on demand. resolveSekAmount silently falls // back to the raw foreign amount when amount_sek/exchange_rate are null, // which means the user would see misleading "kr" values in the verifikation // and the engine would post the wrong number to the books. useEffect(() => { if (!open || !transaction) return const needsRate = !!transaction.currency && transaction.currency !== 'SEK' && (transaction.amount_sek == null || transaction.exchange_rate == null) if (!needsRate) return let cancelled = false setRateLoading(true) setRateError(null) ;(async () => { try { const res = await fetch(`/api/transactions/${transaction.id}/refresh-exchange-rate`, { method: 'POST', }) const json = await res.json() if (cancelled) return if (!res.ok) { setRateError(json?.error?.message || t('exchange_rate_fetch_failed')) return } if (json?.data) { setEnrichedTx({ ...json.data, ...{ potential_invoice: transaction.potential_invoice, potential_supplier_invoice: transaction.potential_supplier_invoice, } }) } } catch { if (!cancelled) setRateError(t('exchange_rate_fetch_failed')) } finally { if (!cancelled) setRateLoading(false) } })() return () => { cancelled = true } }, [open, transaction, t]) if (!transaction || !category) return null const tx = enrichedTx ?? transaction const isIncome = tx.amount > 0 const isCounterpartyTemplate = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0) const isTemplateBooking = !!templateId || isCounterpartyTemplate const isLiabilityAccount = accountOverride.startsWith('2') // For non-SEK transactions, the verifikation and the headline must show // the SEK-converted total — the mall/category booking always posts in SEK. const sekAmount = resolveSekAmount( tx.amount, tx.amount_sek, tx.currency, tx.exchange_rate ) const isForeign = !!(tx.currency && tx.currency !== 'SEK') const sekConversionMissing = isForeign && (tx.amount_sek == null || tx.exchange_rate == null) async function handleConfirm() { if (!category || !transaction) return setIsProcessing(true) setError(null) try { const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment const catDefault = getDefaultAccountForCategory(category) const override = accountOverride && accountOverride !== catDefault ? accountOverride : undefined const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override, templateId) // Link uploaded documents to the journal entry if (journalEntryId && uploadedFiles.length > 0) { const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) let linkFailCount = 0 for (const file of filesToLink) { try { await fetch(`/api/documents/${file.id}/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId }), }) } catch { linkFailCount++ } } if (linkFailCount > 0) { toast({ title: t('doc_link_failed_title'), description: t('doc_link_failed_description', { count: linkFailCount }), variant: 'destructive', }) } } setUploadedFiles([]) setShowUploadZone(false) } catch { setError(t('generic_error')) } finally { // Always reset isProcessing — without this, an onConfirm that resolves // with null (e.g. server returned a structured 4xx error like // ACCOUNTS_NOT_IN_CHART) leaves the dialog frozen because the // below disables backdrop/ESC while processing. setIsProcessing(false) } } return ( { if (!o) { setUploadedFiles([]) setShowUploadZone(false) } onOpenChange(o) }}> {t('title')} {isTemplateBooking ? t('description_template') : t('description_default')} {/* Transaction summary */}
{isIncome ? ( ) : ( )}

{tx.description}

{formatDate(tx.date)}

{isForeign ? ( <>

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

{rateLoading || sekConversionMissing ? t('amount_loading') : t('amount_approx', { sign: isIncome ? '+' : '', sek: formatCurrency(sekAmount, 'SEK') })}

) : (

{isIncome ? '+' : ''} {formatCurrency(sekAmount, 'SEK')}

)}
{isForeign && tx.exchange_rate != null && tx.exchange_rate_date && !sekConversionMissing && (

{t('rate_footnote', { rate: formatCurrency(tx.exchange_rate, 'SEK'), currency: tx.currency, date: formatDate(tx.exchange_rate_date), })}

)} {rateError && (

{rateError}

)} {/* Template or Category */}
{template ? template.name_sv : categoryLabel} {onChangeTemplate && !isCounterpartyTemplate && ( )}
{template && !isCounterpartyTemplate && (

D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)}

)}
{/* Template special rules */} {template?.special_rules_sv && (

{template.special_rules_sv}

)} {/* Deductibility note */} {template?.deductibility_note_sv && (

{template.deductibility_note_sv}

)} {/* Reverse charge warning */} {template?.requires_vat_registration_data && (

{t('reverse_charge_warning')}

)} {/* Journal entry preview — hidden until we have a SEK conversion; otherwise we'd render a verifikation in the wrong currency. */} {!sekConversionMissing && !rateLoading && ( )} {/* Account & VAT — hidden for template bookings (accounts defined by the template) */} {!isTemplateBooking && ( <>
{isLiabilityAccount ? (

{t('no_vat_liability_account')}

) : showVatDropdown ? ( ) : (

{(() => { const opt = VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment) return opt ? tCat(opt.labelKey) : t('no_vat_default') })()} {' '}

)}
)} {/* Document — either show the doc the inbox attached pre-categorize, or let the user upload one if none is attached yet. */} {preAttachedDocumentId ? (
{t('attached_doc_label')} {t('attached_doc_source')}
) : (
{showUploadZone && (
)}
)} {error && (
{error}
)} {/* Actions */}
) }