'use client' import { useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' import { resolveAccount } from '@/lib/cash-accounts/resolve-account' import type { CashAccount } from '@/types' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { applyTemplate } from '@/lib/bookkeeping/template-library' import { formatCurrency, formatDate, cn } from '@/lib/utils' import LineDimensionFields from '@/components/dimensions/LineDimensionFields' import { Loader2, FileText, AlertTriangle, Check, Plus, Trash2, Paperclip } from 'lucide-react' import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types' import type { TransactionWithInvoice } from './transaction-types' interface BulkBookDialogProps { open: boolean onOpenChange: (open: boolean) => void transactions: TransactionWithInvoice[] onSuccess: () => void } type Mode = 'one_line_per_tx' | 'sum_per_account' type Tab = 'template' | 'manual' interface PreviewLine { account_number: string debit_amount: number credit_amount: number line_description: string | undefined } interface ManualLine { id: string account_number: string debit_amount: string // form-state strings; parsed on send credit_amount: string line_description: string } function round2(n: number): number { return Math.round(n * 100) / 100 } function parseAmount(s: string): number { if (!s) return 0 const cleaned = s.replace(/\s/g, '').replace(',', '.') const n = Number.parseFloat(cleaned) return Number.isFinite(n) ? n : 0 } function newManualLineId(): string { return `ml-${Math.random().toString(36).slice(2, 10)}` } export default function BulkBookDialog({ open, onOpenChange, transactions, onSuccess, }: BulkBookDialogProps) { const { toast } = useToast() const { company } = useCompany() const supabase = useMemo(() => createClient(), []) const t = useTranslations('tx_bulk_book') const [tab, setTab] = useState('template') const [templates, setTemplates] = useState([]) const [loadingTemplates, setLoadingTemplates] = useState(true) const [selectedTemplateId, setSelectedTemplateId] = useState(null) // null = fetch pending; array = loaded (may be empty on error: falls back to '1930') const [cashAccounts, setCashAccounts] = useState(null) const [mode, setMode] = useState('one_line_per_tx') const [description, setDescription] = useState('') const [manualLines, setManualLines] = useState([]) const [submitting, setSubmitting] = useState(false) // Dimension tagging (kostnadsställe/projekt): the pair renders only when // company_settings.dimensions_enabled, same gate as JournalEntryForm. One // header-level default bag applies to both tabs; the server tags the // generated voucher's lines with it. const [dimensionsEnabled, setDimensionsEnabled] = useState(false) const [defaultDims, setDefaultDims] = useState>({}) // Documents that will inherit onto the new verifikat. Computed from // transactions.document_id; the RPC reads these and updates each doc's // journal_entry_id atomically with the verifikat commit. const docCount = useMemo( () => transactions.filter((tx) => tx.document_id).length, [transactions], ) const txCount = transactions.length const sharedDate = transactions[0]?.date const sharedCurrency = transactions[0]?.currency ?? 'SEK' const direction: 'income' | 'expense' = useMemo(() => { if (transactions.length === 0) return 'income' return transactions[0]!.amount > 0 ? 'income' : 'expense' }, [transactions]) const txSumAbs = useMemo( () => round2(transactions.reduce((s, tx) => s + Math.abs(tx.amount), 0)), [transactions], ) // Currency homogeneity. A samlingsverifikation must be expressed in one // redovisningsvaluta (BFL 4 kap 6 §), so `txSumAbs` above is only a // meaningful number while every selected tx shares a currency: across // currencies it would add 100 EUR to 100 SEK as if they were one unit. // We therefore split the selection per currency and, when it spans more // than one, never render the combined scalar and never let confirm fire. // NULL currency is legacy for the column default 'SEK'. const currencyTotals = useMemo(() => { const byCurrency = new Map() for (const tx of transactions) { const code = tx.currency ?? 'SEK' byCurrency.set(code, round2((byCurrency.get(code) ?? 0) + Math.abs(tx.amount))) } return Array.from(byCurrency.entries()).sort(([a], [b]) => a.localeCompare(b)) }, [transactions]) const isMixedCurrency = currencyTotals.length > 1 const selectedTemplate = useMemo( () => templates.find((tpl) => tpl.id === selectedTemplateId) ?? null, [templates, selectedTemplateId], ) // Load templates when the dialog opens. RLS scopes to user's companies + // system templates; no company_id filter needed. useEffect(() => { if (!open || !company) return let cancelled = false async function load() { setLoadingTemplates(true) try { const { data } = await supabase .from('booking_template_library') .select('*') .eq('is_active', true) .order('is_system', { ascending: false }) .order('name', { ascending: true }) if (cancelled) return setTemplates((data ?? []) as BookingTemplateLibrary[]) } finally { if (!cancelled) setLoadingTemplates(false) } } load() return () => { cancelled = true } }, [open, company, supabase]) // Fetch cash accounts once when the dialog opens so the manual bank-leg // pre-fill can resolve the correct ledger account per transaction. useEffect(() => { if (!open) return setCashAccounts(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 setCashAccounts((json.data ?? []) as CashAccount[]) }) .catch(() => { // Fall back to empty list: resolveAccount will return '1930' if (!cancelled) setCashAccounts([]) }) return () => { cancelled = true } }, [open]) // Company settings gate the dimension affordance (dimensions_enabled). // Fetched once per open; on failure the pair 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]) // Reset state when dialog closes so the next open starts clean. useEffect(() => { if (!open) { setTab('template') setSelectedTemplateId(null) setMode('one_line_per_tx') setDescription('') setManualLines([]) setCashAccounts(null) setDefaultDims({}) } else if (sharedDate) { // Pre-fill description with a sensible default the user can edit. setDescription(t('default_description', { date: sharedDate })) } }, [open, sharedDate, t]) // Pre-fill the bank side from the txs (one line per tx with the resolved // ledger account and correct Dr/Cr direction). We intentionally do NOT // pre-fill a counterpart account: swedish-compliance flagged that a // hardcoded 3001/5800 prefill nudges users into submitting verifikat // without a VAT line (26xx) for momsregistrerade affärshändelser. The // bank side is the unambiguous part the user always wants; the // counterpart (and any VAT split) is the user's responsibility. // Gate on cashAccounts !== null so lines are only built after the account // fetch resolves: this prevents the form from briefly showing '1930' when // the resolved account differs. useEffect(() => { if (tab !== 'manual') return if (manualLines.length > 0) return if (transactions.length === 0) return if (cashAccounts === null) return const isIncome = direction === 'income' const bankLines: ManualLine[] = transactions.map((tx) => { const { account } = resolveAccount( cashAccounts, tx.cash_account_id ?? null, tx.currency ?? 'SEK', ) return { id: newManualLineId(), account_number: account, debit_amount: isIncome ? Math.abs(tx.amount).toFixed(2).replace('.', ',') : '', credit_amount: isIncome ? '' : Math.abs(tx.amount).toFixed(2).replace('.', ','), line_description: (tx.description || '').slice(0, 40).trim(), } }) // One empty counterpart row to scaffold the next entry. Account // left blank: user must choose, which avoids the no-VAT trap. const counterpart: ManualLine = { id: newManualLineId(), account_number: '', debit_amount: '', credit_amount: '', line_description: '', } setManualLines([...bankLines, counterpart]) }, [tab, manualLines.length, transactions, direction, cashAccounts]) // Live line preview: driven by either the template/mode pair (template // tab) or the user-edited manual lines (manual tab). Same downstream // invariants (balance + bank-leg match) apply to both paths. const previewLines = useMemo(() => { if (tab === 'manual') { return manualLines .map((ml) => ({ account_number: ml.account_number, debit_amount: round2(parseAmount(ml.debit_amount)), credit_amount: round2(parseAmount(ml.credit_amount)), line_description: ml.line_description.trim() || undefined, })) .filter((l) => l.debit_amount > 0 || l.credit_amount > 0) } if (!selectedTemplate) return [] const templateLines = (selectedTemplate.lines ?? []) as BookingTemplateLibraryLine[] const lines: PreviewLine[] = [] if (mode === 'sum_per_account') { const applied = applyTemplate(templateLines, txSumAbs) for (const fl of applied) { const debit = parseFloat(fl.debit_amount || '0') || 0 const credit = parseFloat(fl.credit_amount || '0') || 0 if (debit === 0 && credit === 0) continue lines.push({ account_number: fl.account_number, debit_amount: round2(debit), credit_amount: round2(credit), line_description: fl.line_description, }) } } else { for (const tx of transactions) { const applied = applyTemplate(templateLines, Math.abs(tx.amount)) for (const fl of applied) { const debit = parseFloat(fl.debit_amount || '0') || 0 const credit = parseFloat(fl.credit_amount || '0') || 0 if (debit === 0 && credit === 0) continue const tag = (tx.description || '').slice(0, 40).trim() lines.push({ account_number: fl.account_number, debit_amount: round2(debit), credit_amount: round2(credit), line_description: tag ? `${fl.line_description ?? ''}, ${tag}`.trim() : fl.line_description, }) } } } return lines }, [tab, manualLines, selectedTemplate, mode, transactions, txSumAbs]) const previewTotals = useMemo(() => { const debit = previewLines.reduce((s, l) => s + l.debit_amount, 0) const credit = previewLines.reduce((s, l) => s + l.credit_amount, 0) return { debit: round2(debit), credit: round2(credit) } }, [previewLines]) // Balance + bank-leg match are the two invariants the RPC will check; we // surface them here so the user knows whether confirm will succeed. const isBalanced = Math.abs(previewTotals.debit - previewTotals.credit) < 0.005 const bankLineNet = previewLines .filter((l) => l.account_number >= '1900' && l.account_number <= '1999') .reduce((s, l) => s + l.debit_amount - l.credit_amount, 0) const expectedBankNet = direction === 'income' ? txSumAbs : -txSumAbs const bankMatches = Math.abs(bankLineNet - expectedBankNet) < 0.005 // The active tab gates which selector must be valid. Both paths still // need a non-empty description, ≥2 lines, balance, bank-leg match, // and (for manual mode) valid 4-digit account numbers: without this, // a 1-3-digit entry escapes the lexicographic bank-account range // check ('193' < '1900' is true), bank match could pass, and the // server's Zod schema rejects with a 400 only after submit. const tabReady = tab === 'template' ? selectedTemplate !== null : manualLines.length > 0 const allAccountsValid = previewLines.every((l) => /^\d{4}$/.test(l.account_number)) const canConfirm = !submitting && !isMixedCurrency && tabReady && description.trim().length > 0 && previewLines.length >= 2 && isBalanced && bankMatches && allAccountsValid function setDefaultDimension(dimNo: string, code: string | null) { setDefaultDims((prev) => { const next = { ...prev } const trimmed = code?.trim() if (trimmed) next[dimNo] = trimmed else delete next[dimNo] return next }) } // Compact display, e.g. "KS01 · P001" (dim-number order) for the preview badge. const dimsSummary = Object.entries(defaultDims) .filter(([, v]) => v) .sort(([a], [b]) => Number(a) - Number(b)) .map(([, v]) => v) .join(' · ') function updateManualLine(id: string, patch: Partial>) { setManualLines((prev) => prev.map((l) => (l.id === id ? { ...l, ...patch } : l))) } function removeManualLine(id: string) { setManualLines((prev) => prev.filter((l) => l.id !== id)) } function addManualLine() { setManualLines((prev) => [ ...prev, { id: newManualLineId(), account_number: '', debit_amount: '', credit_amount: '', line_description: '', }, ]) } async function handleConfirm() { if (!canConfirm) return setSubmitting(true) try { // Build the payload per the active tab. Template path uses the // existing schema branch (template_id + mode). Manual path sends // the user-edited lines directly. // Header-level default dimensions ride as a top-level field on both // branches; only sent when the user actually picked something. const defaultDimensions = dimensionsEnabled && Object.keys(defaultDims).length > 0 ? { default_dimensions: defaultDims } : {} const payload = tab === 'manual' ? { tx_ids: transactions.map((tx) => tx.id), entry_description: description.trim(), manual_lines: previewLines.map((l) => ({ account_number: l.account_number, debit_amount: l.debit_amount, credit_amount: l.credit_amount, currency: sharedCurrency, line_description: l.line_description ?? undefined, })), ...defaultDimensions, } : { tx_ids: transactions.map((tx) => tx.id), template_id: selectedTemplateId, mode, entry_description: description.trim(), ...defaultDimensions, } const response = await fetch('/api/transactions/bulk-book', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!response.ok) { const body = await response.json().catch(() => null) toast({ title: t('error_title'), description: getErrorMessage(body, { statusCode: response.status }), variant: 'destructive', }) return } const body = (await response.json()) as { data: { voucher_series: string | null; voucher_number: number | null } } const voucherLabel = body.data.voucher_series && body.data.voucher_number != null ? `${body.data.voucher_series}-${body.data.voucher_number}` : t('unknown_voucher') toast({ title: t('success_title'), description: t('success_description', { count: txCount, voucher: voucherLabel }), variant: 'success', }) onSuccess() onOpenChange(false) } catch (err) { toast({ title: t('error_title'), description: getErrorMessage(err), variant: 'destructive', }) } finally { setSubmitting(false) } } if (transactions.length === 0) return null // Mixed-currency dead end. This is deliberately NOT the advisory-guard // pattern with a "book anyway" escape: a verifikat spanning two // redovisningsvalutor is not representable at all, so there is no correct // outcome to bypass to. We show the honest per-currency subtotals instead // of one summed scalar, and the booking UI never renders, so the request // is blocked before it is built. The route and the RPC refuse the same // selection with BULK_BOOK_MIXED_CURRENCY. if (isMixedCurrency) { return ( {t('title', { count: txCount, date: sharedDate ? formatDate(sharedDate) : '' })} {t('mixed_currency_description')}

{t('mixed_currency_totals_label')}

    {currencyTotals.map(([code, total]) => (
  • {code} {formatCurrency(total, code)}
  • ))}
{t('mixed_currency_blocked')}
) } return ( {t('title', { count: txCount, date: sharedDate ? formatDate(sharedDate) : '' })} {direction === 'income' ? t('description_income') : t('description_expense')}
{/* Selection summary */}

{t('summary_count', { count: txCount })}

{sharedDate ? formatDate(sharedDate) : ''}

{direction === 'income' ? '+' : '−'} {formatCurrency(txSumAbs, sharedCurrency)}

{/* Tab: Mall (template) / Manuell (hand-built lines). Default template; manual is the "I want to book it myself" escape hatch the user asked for after PR #606. */} setTab(v as Tab)} className="space-y-4"> {t('tab_template')} {t('tab_manual')} {/* Template picker */}
{loadingTemplates ? (
) : templates.length === 0 ? (
{t('no_templates')}
) : (
    {templates.map((tpl) => (
  • ))}
)}
{/* Mode toggle: segmented control pattern (no RadioGroup primitive in the design system; two outlined buttons act as a selectable pair) */} {selectedTemplate && (
)}
{/* Manual line editor. Lines are pre-filled from txs on first switch to this tab (one line per tx on 1930 + counterpart line on 3001/5800). User adjusts accounts, amounts, and descriptions. Live balance + bank-leg checks below drive the confirm button. */}
{manualLines.map((line) => ( ))}
{t('col_account')} {t('col_description')} {t('col_debit')} {t('col_credit')}
updateManualLine(line.id, { account_number: e.target.value.replace(/\D/g, '').slice(0, 4) }) } placeholder="1930" className="h-8 text-xs font-mono" /> updateManualLine(line.id, { line_description: e.target.value.slice(0, 200) }) } placeholder={t('manual_description_placeholder')} className="h-8 text-xs" /> updateManualLine(line.id, { debit_amount: e.target.value }) } placeholder="0,00" className="h-8 text-xs text-right" /> updateManualLine(line.id, { credit_amount: e.target.value }) } placeholder="0,00" className="h-8 text-xs text-right" />
{/* Description: shared by both tabs once the user has either a template selected or manual lines drafted. */} {tabReady && (
setDescription(e.target.value)} maxLength={500} />
)} {/* Header default dims (kostnadsställe/projekt): one bag applied to the whole verifikat, shared by both tabs. */} {dimensionsEnabled && tabReady && (

{t('dimensions_hint')}

)} {/* Document inheritance hint: informs the user which receipts follow the txs onto the combined verifikat. Zero is fine (txs without docs don't break anything); we only render when the count is non-zero to avoid clutter. */} {docCount > 0 && tabReady && (
{t('docs_inherit_hint', { count: docCount })}
)} {/* Live preview */} {tabReady && previewLines.length > 0 && (
{dimensionsEnabled && dimsSummary && ( {dimsSummary} )}
{previewLines.slice(0, 30).map((line, i) => ( ))} {previewLines.length > 30 && ( )}
{t('col_account')} {t('col_description')} {t('col_debit')} {t('col_credit')}
{line.account_number} {line.line_description ?? '-'} {line.debit_amount > 0 ? formatCurrency(line.debit_amount) : ''} {line.credit_amount > 0 ? formatCurrency(line.credit_amount) : ''}
{t('preview_truncated', { remaining: previewLines.length - 30 })}
{t('total_label')} {formatCurrency(previewTotals.debit)} {formatCurrency(previewTotals.credit)}
{/* Invariant indicators */}
{isBalanced ? (
{t('balance_ok')}
) : (
{t('balance_off', { delta: formatCurrency(Math.abs(previewTotals.debit - previewTotals.credit)), })}
)} {bankMatches ? (
{t('bank_ok')}
) : (
{t('bank_off')}
)}
)}
) }