'use client' import { useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { cn, formatCurrency } from '@/lib/utils' import { Dialog, DialogContent, 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 { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { applyTemplate, getTemplateScope, SCOPE_LABELS, TEMPLATE_CATEGORY_LABELS, } from '@/lib/bookkeeping/template-library' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { roundOre } from '@/lib/money' import { ArrowLeft, Check, ChevronRight, Loader2, Search } from 'lucide-react' import type { BookingTemplateLibrary, FiscalPeriod } from '@/types' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' interface Props { open: boolean onOpenChange: (open: boolean) => void /** Fired after a verifikat is booked from a template. */ onCreated: () => void } /** Sum a side of the computed lines in öre-safe steps. */ function sumSide(lines: FormLine[], side: 'debit_amount' | 'credit_amount'): number { return lines.reduce((acc, l) => roundOre(acc + (parseFloat(l[side]) || 0)), 0) } /** * "Bokför från mall" (UI-migration plan PR 4, scene 9): a centered modal * with the template list (existing booking_template_library data, MRU * ordering from the API), then date + editable amount that recomputes the * kontering live via applyTemplate, a "Balanserar" row, and direct booking * (user action, so no Granskning detour). */ export default function TemplateBookDialog({ open, onOpenChange, onCreated }: Props) { const t = useTranslations('bookkeeping') const { toast } = useToast() const [templates, setTemplates] = useState(null) const [periods, setPeriods] = useState([]) const [search, setSearch] = useState('') const [selected, setSelected] = useState(null) const [entryDate, setEntryDate] = useState(() => new Date().toISOString().split('T')[0]) const [amountInput, setAmountInput] = useState('') const [submitting, setSubmitting] = useState(false) // Load templates + fiscal periods when the dialog opens. useEffect(() => { if (!open) return let cancelled = false ;(async () => { const [tplRes, periodRes] = await Promise.all([ fetch('/api/settings/booking-templates'), fetch('/api/bookkeeping/fiscal-periods'), ]) if (cancelled) return if (tplRes.ok) { const { data } = await tplRes.json() if (!cancelled) setTemplates(data ?? []) } else { setTemplates([]) } if (periodRes.ok) { const { data } = await periodRes.json() if (!cancelled) setPeriods(data ?? []) } })() return () => { cancelled = true } }, [open]) // Reset per open so yesterday's half-typed amount never leaks into today. useEffect(() => { if (open) return setSelected(null) setSearch('') setAmountInput('') setEntryDate(new Date().toISOString().split('T')[0]) }, [open]) const amount = useMemo(() => { const parsed = parseFloat(amountInput.replace(/\s/g, '').replace(',', '.')) return Number.isFinite(parsed) && parsed > 0 ? roundOre(parsed) : 0 }, [amountInput]) // The live kontering: recomputed from the template's line pattern on // every amount change (momssplit etc. handled by applyTemplate). const lines = useMemo( () => (selected && amount > 0 ? applyTemplate(selected.lines, amount) : []), [selected, amount], ) const totalDebit = sumSide(lines, 'debit_amount') const totalCredit = sumSide(lines, 'credit_amount') const balanced = lines.length >= 2 && totalDebit === totalCredit && totalDebit > 0 const filteredTemplates = (templates ?? []).filter((tpl) => tpl.name.toLowerCase().includes(search.trim().toLowerCase()), ) const periodForDate = periods.find( (p) => p.period_start <= entryDate && entryDate <= p.period_end, ) const handleBook = async () => { if (!selected || !balanced) return if (!periodForDate) { toast({ title: t('tpl_no_period'), variant: 'destructive' }) return } setSubmitting(true) try { const res = await fetch('/api/bookkeeping/journal-entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fiscal_period_id: periodForDate.id, entry_date: entryDate, description: selected.name, lines: lines.map((l) => ({ account_number: l.account_number, debit_amount: parseFloat(l.debit_amount) || 0, credit_amount: parseFloat(l.credit_amount) || 0, line_description: l.line_description || undefined, })), }), }) const result = await res.json() if (!res.ok) { toast({ title: t('toast_post_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive', }) return } // MRU ordering for the next open; fire-and-forget. void fetch(`/api/settings/booking-templates/${selected.id}/touch`, { method: 'POST', }).catch(() => {}) toast({ title: t('toast_posted_title'), description: t('toast_posted_description', { voucher: formatVoucher(result.data ?? {}), }), }) onOpenChange(false) onCreated() } catch { toast({ title: t('toast_post_failed_generic'), variant: 'destructive' }) } finally { setSubmitting(false) } } return ( !submitting && onOpenChange(next)}> {selected ? selected.name : t('tpl_dialog_title')} {!selected ? ( <>
setSearch(e.target.value)} placeholder={t('tpl_search_placeholder')} className="w-full bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none" autoFocus />
{templates === null ? (
) : filteredTemplates.length === 0 ? (

{t('tpl_empty')}

) : ( filteredTemplates.map((tpl) => ( )) )}
) : (
setEntryDate(e.target.value)} />
setAmountInput(e.target.value)} autoFocus className="tabular-nums" />
{/* Live kontering preview */}
{lines.length === 0 ? (

{t('tpl_enter_amount')}

) : ( <> {lines.map((l, i) => (
{l.account_number} {l.line_description} {l.debit_amount ? formatCurrency(parseFloat(l.debit_amount)) : ''} {l.credit_amount ? formatCurrency(parseFloat(l.credit_amount)) : ''}
))}
{balanced && } {balanced ? t('tpl_balances') : t('tpl_not_balancing')} {formatCurrency(totalDebit)} / {formatCurrency(totalCredit)}
)}
)}
) }