'use client' import { useTranslations } from 'next-intl' import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 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 { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog' import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe, Pencil, Copy } from 'lucide-react' import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate, applyTemplate } from '@/lib/bookkeeping/template-library' import { useCanWrite } from '@/lib/hooks/use-can-write' import { InfoTooltip } from '@/components/ui/info-tooltip' import { formatCurrency } from '@/lib/utils' import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types' export function BookingTemplatesPanel() { const t = useTranslations('settings_booking_templates') const { toast } = useToast() const { canWrite } = useCanWrite() const ENTITY_LABELS: Record = { all: t('entity_all'), enskild_firma: t('entity_enskild_firma'), aktiebolag: t('entity_aktiebolag'), } const [templates, setTemplates] = useState([]) const [isLoading, setIsLoading] = useState(true) const [deletingId, setDeletingId] = useState(null) const [expandedId, setExpandedId] = useState(null) const [showCreate, setShowCreate] = useState(false) // Shared dialog for editing a company/team template or customizing (duplicating) // a read-only system template. Mode is derived from is_system. const [activeTemplate, setActiveTemplate] = useState(null) const importRef = useRef(null) const fetchTemplates = useCallback(async () => { try { const res = await fetch('/api/settings/booking-templates') const json = await res.json() if (json.data) setTemplates(json.data) } catch { toast({ title: t('toast_fetch_failed'), variant: 'destructive' }) } finally { setIsLoading(false) } }, [toast, t]) useEffect(() => { fetchTemplates() }, [fetchTemplates]) async function handleDelete(id: string) { setDeletingId(id) try { const res = await fetch('/api/settings/booking-templates', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) if (!res.ok) { toast({ title: t('toast_delete_failed'), variant: 'destructive' }) return } setTemplates((prev) => prev.filter((tt) => tt.id !== id)) toast({ title: t('toast_deleted') }) } finally { setDeletingId(null) } } async function handleExport() { try { const res = await fetch('/api/settings/booking-templates/export') const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = 'bokforingsmallar.json' a.click() URL.revokeObjectURL(url) } catch { toast({ title: t('toast_export_failed'), variant: 'destructive' }) } } async function handleImport(e: React.ChangeEvent) { const file = e.target.files?.[0] if (!file) return try { const text = await file.text() const payload = JSON.parse(text) const res = await fetch('/api/settings/booking-templates/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) const json = await res.json() if (!res.ok) { toast({ title: t('toast_import_error'), description: json.error || t('toast_import_generic'), variant: 'destructive' }) return } toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) }) fetchTemplates() } catch { toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' }) } finally { // Reset input so same file can be imported again if (importRef.current) importRef.current.value = '' } } // Group templates by scope const systemTemplates = templates.filter((tt) => tt.is_system) const teamTemplates = templates.filter((tt) => tt.team_id && !tt.is_system) const companyTemplates = templates.filter((tt) => tt.company_id && !tt.is_system) // Names of existing company templates — used for a soft "name already exists" // hint when creating or customizing (never blocks save). const companyTemplateNames = companyTemplates.map((tt) => tt.name) return ( <>
{t('title')} {t('description')}
{canWrite && (
{t('create_dialog_title')} { setShowCreate(false) fetchTemplates() }} />
)}
{isLoading ? (
) : templates.length === 0 ? (

{t('empty_state')}

) : (
{/* System templates */} {systemTemplates.length > 0 && ( )} {/* Team templates */} {teamTemplates.length > 0 && ( )} {/* Company templates */} {companyTemplates.length > 0 && ( )}
)}
{/* Shared edit / customize dialog. Editing a company or team template uses PUT; customizing a read-only system template creates a company-scoped copy via POST. The form is keyed by template id so it re-seeds state when switching between rows. */} { if (!open) setActiveTemplate(null) }}> {activeTemplate?.is_system ? t('customize_dialog_title') : t('edit_dialog_title')} {activeTemplate && ( { setActiveTemplate(null) fetchTemplates() }} /> )} ) } function TemplateSection({ title, icon: Icon, templates, expandedId, onToggle, deletingId, onDelete, canDelete, canEdit = false, canCustomize = false, onEdit, onCustomize, entityLabels, }: { title: string icon: React.ComponentType<{ className?: string }> templates: BookingTemplateLibrary[] expandedId: string | null onToggle: (id: string | null) => void deletingId: string | null onDelete: (id: string) => void canDelete: boolean canEdit?: boolean canCustomize?: boolean onEdit?: (template: BookingTemplateLibrary) => void onCustomize?: (template: BookingTemplateLibrary) => void entityLabels: Record }) { const t = useTranslations('settings_booking_templates') return (

{title}

{templates.length}
{templates.map((tt) => { const isExpanded = expandedId === tt.id const isConvertible = convertLibraryToBookingTemplate(tt) !== null return (
{canCustomize && onCustomize && ( )} {canEdit && onEdit && ( )} {canDelete && ( )}
{isExpanded && (
{tt.description && (

{tt.description}

)} {tt.lines.map((line: BookingTemplateLibraryLine, i: number) => ( ))}
{t('th_account')} {t('th_description')} {t('th_type')} {t('th_debit')} {t('th_credit')}
{line.account} {line.label} {line.type === 'vat' && line.vat_rate ? t('vat_with_rate', { rate: (line.vat_rate * 100).toFixed(0) }) : line.type === 'settlement' ? t('type_settlement') : t('type_cost_revenue')} {line.side === 'debit' ? t('debit_short') : ''} {line.side === 'credit' ? t('credit_short') : ''}
)}
) })}
) } type TemplateFormMode = 'create' | 'edit' | 'duplicate' 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} />