'use client' import { useTranslations } from 'next-intl' import { useState, useMemo } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' import { Loader2, Trash2, Plus } from 'lucide-react' import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate, applyTemplate } from '@/lib/bookkeeping/template-library' import { InfoTooltip } from '@/components/ui/info-tooltip' import { formatCurrency } from '@/lib/utils' import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types' export type TemplateFormMode = 'create' | 'edit' | 'duplicate' /** * Shared editor for booking templates. Seeds its state once from * `initialTemplate` (used by the settings panel to edit/customize a template, * and by "Spara som mall" in the booking dialog to pre-fill a derived draft). * * Submit behaviour is driven by `mode`: 'edit' PUTs the existing template, * 'create' and 'duplicate' both POST a new company-scoped template. */ export function TemplateForm({ mode, initialTemplate, entityLabels, duplicateNamePool = [], onSaved, }: { mode: TemplateFormMode initialTemplate?: BookingTemplateLibrary entityLabels: Record duplicateNamePool?: string[] onSaved: () => void }) { const t = useTranslations('settings_booking_templates') const { toast } = useToast() const [isSubmitting, setIsSubmitting] = useState(false) // When customizing a system template (mode 'duplicate') we suggest a distinct // "(anpassad)" name so the company copy doesn't read as the standard one. const [name, setName] = useState(() => initialTemplate ? mode === 'duplicate' ? t('copy_name_suffix', { name: initialTemplate.name }) : initialTemplate.name : '', ) const [description, setDescription] = useState(initialTemplate?.description ?? '') const [category, setCategory] = useState(initialTemplate?.category ?? 'other') const [entityType, setEntityType] = useState<'all' | 'enskild_firma' | 'aktiebolag'>( initialTemplate?.entity_type ?? 'all', ) const [lines, setLines] = useState(() => initialTemplate ? initialTemplate.lines.map((l) => ({ ...l })) : [ { account: '', label: '', side: 'debit', type: 'business', ratio: 1 }, { account: '', label: '', side: 'credit', type: 'settlement', ratio: 1 }, ], ) function updateLine(index: number, field: keyof BookingTemplateLibraryLine, value: string | number) { setLines((prev) => { const updated = [...prev] updated[index] = { ...updated[index], [field]: value } return updated }) } function updateLineType(index: number, newType: BookingTemplateLibraryLine['type']) { setLines((prev) => { const updated = [...prev] const current = updated[index] const next: BookingTemplateLibraryLine = { ...current, type: newType } // Auto-pick a sensible default for the type-specific field so the // converter (and applyTemplate) sees a complete line shape. if (newType === 'vat' && next.vat_rate === undefined) { next.vat_rate = 0.25 } updated[index] = next return updated }) } // Default new lines to a VAT line — the 2-line template starts with one // business + one settlement, and the natural extension is a VAT leg. // Defaulting to 'business' instead would silently break the converter // (which requires exactly one business line) and the template would // disappear from the transaction picker. function addLine() { setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'vat', vat_rate: 0.25 }]) } function removeLine(index: number) { if (lines.length <= 2) return setLines((prev) => prev.filter((_, i) => i !== index)) } // Ratio is only load-bearing when a template splits the amount across more // than one cost/revenue line. Hide it for the simple case to keep the form // approachable for non-accountants; it stays 1.0 under the hood. const businessLineCount = lines.filter((l) => l.type === 'business').length const showRatio = businessLineCount > 1 // The ratio only validates against cost/revenue lines (businessRatioSum), so // only those get an editable input. The settlement leg is the full counter- // amount (ratio 1.0) and is shown in the live preview, not as a control — // an editable settlement ratio that doesn't feed the sum check would mislead. const firstRatioIndex = showRatio ? lines.findIndex((l) => l.type === 'business') : -1 const businessRatioSum = lines .filter((l) => l.type === 'business') .reduce((sum, l) => sum + (l.ratio ?? 1), 0) const ratioSumOff = showRatio && Math.abs(businessRatioSum - 1) > 0.001 // Live split preview for a 1 000 kr amount. Computed only once every line has // an account so the table doesn't flicker while the form is half-filled. const preview = useMemo(() => { if (lines.some((l) => !l.account)) return null try { return applyTemplate(lines, 1000) } catch { return null } }, [lines]) // Soft, non-blocking hint when the chosen name collides with an existing // company template (no DB unique constraint — duplicates are allowed). const nameCollision = mode !== 'edit' && name.trim().length > 0 && duplicateNamePool.some((n) => n.trim().toLowerCase() === name.trim().toLowerCase()) // Real-time check: can this draft be picked from the transaction sheet? // If not, we show a hint — save remains allowed (templates may still be // useful from the journal-entry form). const isConvertible = (() => { const draft: BookingTemplateLibrary = { id: initialTemplate?.id ?? '', company_id: null, team_id: null, created_by: null, name, description, category, entity_type: entityType, lines, is_system: false, is_active: true, created_at: '', updated_at: '', } return convertLibraryToBookingTemplate(draft) !== null })() async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!name || lines.some((l) => !l.account || !l.label)) { toast({ title: t('toast_fill_all_fields'), variant: 'destructive' }) return } setIsSubmitting(true) try { // Edit updates the existing template in place (PUT); create and duplicate // both write a new company-scoped template (POST). const isEdit = mode === 'edit' const url = isEdit ? `/api/settings/booking-templates/${initialTemplate!.id}` : '/api/settings/booking-templates' const res = await fetch(url, { method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description, category, entity_type: entityType, lines }), }) if (!res.ok) { const json = await res.json().catch(() => ({})) toast({ title: json.error || t('toast_create_failed'), variant: 'destructive' }) return } toast({ title: isEdit ? t('toast_updated') : t('toast_created') }) onSaved() } finally { setIsSubmitting(false) } } return (
setName(e.target.value)} placeholder={t('name_placeholder')} autoFocus={mode === 'duplicate'} onFocus={mode === 'duplicate' ? (e) => e.target.select() : undefined} />