'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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' 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 { Loader2, FileText, AlertTriangle, Check } 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' interface PreviewLine { account_number: string debit_amount: number credit_amount: number line_description: string | undefined } function round2(n: number): number { return Math.round(n * 100) / 100 } 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 [templates, setTemplates] = useState([]) const [loadingTemplates, setLoadingTemplates] = useState(true) const [selectedTemplateId, setSelectedTemplateId] = useState(null) const [mode, setMode] = useState('one_line_per_tx') const [description, setDescription] = useState('') const [submitting, setSubmitting] = useState(false) 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], ) 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]) // Reset state when dialog closes so the next open starts clean. useEffect(() => { if (!open) { setSelectedTemplateId(null) setMode('one_line_per_tx') setDescription('') } else if (sharedDate) { // Pre-fill description with a sensible default the user can edit. setDescription(t('default_description', { date: sharedDate })) } }, [open, sharedDate, t]) // Live line preview — recomputes when template/mode/tx-set changes. const previewLines = useMemo(() => { 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 }, [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 const canConfirm = !submitting && selectedTemplate !== null && description.trim().length > 0 && previewLines.length >= 2 && isBalanced && bankMatches async function handleConfirm() { if (!canConfirm) return setSubmitting(true) try { const response = await fetch('/api/transactions/bulk-book', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tx_ids: transactions.map((tx) => tx.id), template_id: selectedTemplateId, mode, entry_description: description.trim(), }), }) 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 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)}

{/* 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 && (
)} {/* Description */} {selectedTemplate && (
setDescription(e.target.value)} maxLength={500} />
)} {/* Live preview */} {selectedTemplate && previewLines.length > 0 && (
{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')}
)}
)}
) }