'use client' import { useState, useEffect, useCallback } from 'react' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' import { formatCurrency, formatDate } from '@/lib/utils' import { linkDocuments, formatFailedDocumentNames } from '@/lib/documents/link-documents' import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react' import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping' import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' import { getVatRate } from '@/lib/bookkeeping/vat-entries' import type { ReviewTemplate } from '@/lib/transactions/quick-review-defaults' import { resolveExplicitVat } from '@/lib/transactions/quick-review-defaults' 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 LineDimensionFields from '@/components/dimensions/LineDimensionFields' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane' 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' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' interface QuickReviewDialogProps { open: boolean onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null category: TransactionCategory | null categoryLabel: string /** Empty string when there is no sensible default: never undefined. */ defaultAccount: string defaultVat: VatTreatment | 'none' entityType?: EntityType template?: ReviewTemplate | null templateId?: string counterpartyLinePattern?: LinePatternEntry[] | null /** * Learned bag from the counterparty template (default_dimensions): prefills * the picker so the user sees, and can change, what the booking will be * tagged with. */ counterpartyDefaultDimensions?: Record | null onConfirm: ( id: string, category: TransactionCategory, vatTreatment: VatTreatment | undefined, accountOverride: string | undefined, templateId?: string, dimensions?: Record ) => Promise onChangeTemplate?: () => void } export default function QuickReviewDialog({ open, onOpenChange, transaction, category, categoryLabel, defaultAccount, defaultVat, entityType, template, templateId, counterpartyLinePattern, counterpartyDefaultDimensions, onConfirm, onChangeTemplate, }: QuickReviewDialogProps) { const t = useTranslations('tx_quick_review') const tCat = useTranslations('tx_categories') const { toast } = useToast() const router = useRouter() // `?? ''` is deliberate belt-and-braces: the prop is a required string, but // a caller that hands over a template-shaped object missing debit_account // used to make this undefined and take the whole page down on the // .startsWith() below. An empty account disables the confirm button; it // never throws. 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) // 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) // Dimension tagging (kostnadsställe/projekt): the picker renders only when // company_settings.dimensions_enabled, same gate as BulkBookDialog. Seeded // from the counterparty template's learned bag so the user sees what the // booking will carry and can change it. const [dimensionsEnabled, setDimensionsEnabled] = useState(false) const [dims, setDims] = useState>( () => ({ ...(counterpartyDefaultDimensions ?? {}) }), ) const preAttachedDocumentId = transaction?.document_id ?? null // 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) setDims({ ...(counterpartyDefaultDimensions ?? {}) }) // Re-seeding on counterpartyDefaultDimensions alone would clobber in- // flight edits; the bag only changes together with the transaction. // eslint-disable-next-line react-hooks/exhaustive-deps }, [transaction]) // Company settings gate the dimension affordance (dimensions_enabled). // Fetched once per open; on failure the picker simply stays hidden. useEffect(() => { if (!open) return let cancelled = false fetch('/api/settings') .then((r) => r.json()) .then(({ data }) => { if (!cancelled) setDimensionsEnabled(data?.dimensions_enabled === true) }) .catch(() => { if (!cancelled) setDimensionsEnabled(false) }) return () => { cancelled = true } }, [open]) // 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(getUserErrorMessage(json?.error) || 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 // Keyed off the template ID, not off the presence of a line pattern: a // *learned* counterparty (one business line, no pattern) is still booked // server-side from counterparty_template_id, so its accounts and VAT come // from the stored template. Deciding this from counterparties that happen // to have a multi-line pattern made single-line ones fall through to the // category branch, which previewed the wrong accounts and offered an // account/VAT editor whose values the categorize route discards. const isCounterpartyTemplate = !!template?.id && isCounterpartyTemplateId(template.id) const hasCounterpartyPattern = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0) const isTemplateBooking = !!templateId || isCounterpartyTemplate const isLiabilityAccount = accountOverride?.startsWith('2') ?? false // 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) // Dimensions carried by the counterparty template's line pattern (dimensions // PR7). Business lines may each carry a {sie_dim_no: code} bag: merge them // into one compact display label ("KS01 · P001", dim-number order). This is // display-only: booking applies the pattern's bags server-side. const patternDims: Record = {} for (const line of counterpartyLinePattern ?? []) { if (line.dimensions) Object.assign(patternDims, line.dimensions) } const patternDimsLabel = Object.entries(patternDims) .filter(([, code]) => code) .sort(([a], [b]) => Number(a) - Number(b)) .map(([, code]) => code) .join(' · ') async function handleConfirm() { if (!category || !transaction) return setIsProcessing(true) setError(null) try { // 'none' as the seeded default stays off the wire (server derives, no // VAT line); 'none' as a user deviation goes as explicit 'exempt'. The // old unconditional collapse re-derived the default server-side and // booked 25% moms against an explicit "Ingen moms" while the preview // showed none. See resolveExplicitVat. const resolvedVat = resolveExplicitVat(vatTreatment, defaultVat) const catDefault = getDefaultAccountForCategory(category) const override = accountOverride && accountOverride !== catDefault ? accountOverride : undefined // Cleared combobox values leave empty strings behind; strip them so an // untouched picker sends no bag at all (learned template bags then apply // server-side unchanged). const cleanedDims = Object.fromEntries( Object.entries(dims).filter(([, code]) => code && code.trim().length > 0), ) const journalEntryId = await onConfirm( transaction.id, category, resolvedVat, override, templateId, Object.keys(cleanedDims).length > 0 ? cleanedDims : undefined, ) // Attach the uploaded underlag to the verifikat the booking just created. // BFL 5 kap 7 § requires the verifikation to reference its underlag and // BFL 7 kap requires that underlag to be archived with it; the verifikat // is already committed here, so a failed link can only be reported, not // undone. The parent's "Bokförd" toast must not be the last word when a // receipt never made it onto the books. if (journalEntryId && uploadedFiles.length > 0) { const targets = uploadedFiles .filter((f) => f.status === 'uploaded' && f.id) .map((f) => ({ documentId: f.id as string, fileName: f.fileName })) const { failed } = await linkDocuments(targets, journalEntryId) if (failed.length > 0) { toast({ title: t('doc_link_failed_booked_title'), description: t('doc_link_failed_booked_description', { count: failed.length, files: formatFailedDocumentNames(failed), }), variant: 'destructive', action: ( router.push(`/bookkeeping/${journalEntryId}`)} > {t('doc_link_open_entry')} ), }) // By this point the parent has already closed the dialog (onConfirm // resolved before linkDocuments did), so this component's file state // is invisible either way: the toast above, with its open-entry // action, is the user's actual pointer to the underlag that did not // attach. The early return just skips the redundant cleanup below. return } } 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')} {/* When a document is pre-attached, show it side-by-side (receipt left, review right). With no document the wrappers use display:contents so the dialog collapses to the original single-column layout. */}
{preAttachedDocumentId && (
)}
{/* 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} {patternDimsLabel && ( {patternDimsLabel} )} {onChangeTemplate && !hasCounterpartyPattern && ( )}
{/* Only when there IS a single debit/credit pair to show: a multi-line counterparty pattern has none, and a template that never carried accounts would render "D: → K: ". */} {!hasCounterpartyPattern && template?.debit_account && template?.credit_account && (

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') })()} {' '}

)}
)} {/* Dimension tags (kostnadsställe/projekt): rendered for category, library-template and legacy counterparty bookings. Multi-line counterparty patterns are excluded: their per-line bags are authoritative server-side and an edit here would be ignored. */} {dimensionsEnabled && !hasCounterpartyPattern && (
{ setDims((prev) => { const next = { ...prev } if (code) next[sieDimNo] = code else delete next[sieDimNo] return next }) }} inputClassName="h-8" />
)} {/* No pre-attached document: let the user upload one. (When a document IS pre-attached it's shown in the left preview column instead.) */} {!preAttachedDocumentId && (
{showUploadZone && (
)}
)} {error && (
{error}
)} {/* Actions */}
) }