'use client' import { useLocale, useTranslations } from 'next-intl' import { useState, useEffect, useCallback, useRef } from 'react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { HelpPopover } from '@/components/ui/help-popover' import { useToast } from '@/components/ui/use-toast' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog' import { SettingsGroup } from '@/components/settings/SettingsRows' import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Pencil, Copy } from 'lucide-react' import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library' import { useCanWrite } from '@/lib/hooks/use-can-write' import { TemplateForm } from '@/components/settings/TemplateForm' import { downloadFile } from '@/lib/browser/download-file' import type { ErrorLocale } from '@/lib/errors/get-error-message' import { cn } from '@/lib/utils' import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types' export function BookingTemplatesPanel() { const t = useTranslations('settings_booking_templates') const locale = useLocale() as ErrorLocale 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) const [isExporting, setIsExporting] = 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() { // The button is disabled while a run is in flight; this also covers the // keyboard/double-click race before React has re-rendered it. if (isExporting) return setIsExporting(true) try { const result = await downloadFile({ url: '/api/settings/booking-templates/export', filename: 'bokforingsmallar.json', locale, }) // Success is silent on purpose: the saved file is the feedback. On // failure nothing was written to disk, so exactly one toast tells the // user why. Never two: TOAST_LIMIT is 1, so a second toast in the same // tick evicts the first and only the last one is ever rendered. if (!result.ok) { toast({ title: t('toast_export_failed'), description: result.reason === 'timeout' ? t('toast_export_timeout') : result.message, variant: 'destructive', }) } } finally { setIsExporting(false) } } 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 ( <> {/* Group eyebrow with the panel's actions on the right: export/import as quiet buttons, "Ny mall" as the one pill. The old card description lives behind the "?". */}

{t('title')} {t('description')}

{canWrite && (
{t('create_dialog_title')} { setShowCreate(false) fetchTemplates() }} />
)}
{isLoading ? (
) : templates.length === 0 ? (

{t('empty_state')}

) : null}
{!isLoading && templates.length > 0 && ( <> {/* 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, templates, expandedId, onToggle, deletingId, onDelete, canDelete, canEdit = false, canCustomize = false, onEdit, onCustomize, entityLabels, }: { title: 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') const tCommon = useTranslations('common') return ( {/* Origin eyebrow with count; mirrors SettingsGroup's label line. */}

{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') : ''}
)}
) })}
) }