'use client' import { useLocale, useTranslations } from 'next-intl' import { useState, useEffect, 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, Eye, EyeOff } 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' import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { useBookingTemplates } from '@/lib/reference-data/hooks' 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'), } // The panel renders the same session-cached list the pickers use // (lib/reference-data); every write below invalidates it so registry and // pickers can never disagree. const { templates, isLoading, error: templatesError } = useBookingTemplates() const [deletingId, setDeletingId] = useState(null) const [hidingId, setHidingId] = useState(null) const [showHidden, setShowHidden] = useState(false) 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) useEffect(() => { if (templatesError) toast({ title: t('toast_fetch_failed'), variant: 'destructive' }) }, [templatesError, toast, t]) const refreshTemplates = () => { void invalidateReferenceData('ref:booking-templates') } 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 } // This list and every picker read the session cache: refresh it. void invalidateReferenceData('ref:booking-templates') toast({ title: t('toast_deleted') }) } finally { setDeletingId(null) } } // Hide/unhide a system template for the current company only. Opt-in per // company: hidden templates stay listed in the collapsed section below so // nothing ever disappears silently. async function handleToggleHidden(id: string, hide: boolean) { setHidingId(id) try { const res = await fetch(`/api/settings/booking-templates/${id}/hide`, { method: hide ? 'POST' : 'DELETE', }) if (!res.ok) { toast({ title: hide ? t('toast_hide_failed') : t('toast_unhide_failed'), variant: 'destructive', }) return } void invalidateReferenceData('ref:booking-templates') toast({ title: hide ? t('toast_hidden') : t('toast_unhidden') }) } catch { // fetch itself rejected (network); same failure toast as a non-ok status. toast({ title: hide ? t('toast_hide_failed') : t('toast_unhide_failed'), variant: 'destructive', }) } finally { setHidingId(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 }) }) refreshTemplates() } 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. Hidden system templates get their own collapsed // section instead of vanishing: the hide feature is per-company and always // reversible from here. const systemTemplates = templates.filter((tt) => tt.is_system && !tt.is_hidden) const hiddenSystemTemplates = templates.filter((tt) => tt.is_system && tt.is_hidden) 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) refreshTemplates() }} />
)}
{isLoading ? (
) : templates.length === 0 ? (

{t('empty_state')}

) : null}
{!isLoading && templates.length > 0 && ( <> {/* System templates */} {systemTemplates.length > 0 && ( handleToggleHidden(tt.id, true)} hidingId={hidingId} entityLabels={ENTITY_LABELS} /> )} {/* Hidden system templates: collapsed by default, restore per row. Kept visible as a section so hiding is never silent. */} {hiddenSystemTemplates.length > 0 && ( {showHidden && (
{hiddenSystemTemplates.map((tt) => (
{tt.name} {TEMPLATE_CATEGORY_LABELS[tt.category]} {tt.entity_type !== 'all' && ` · ${ENTITY_LABELS[tt.entity_type]}`} {canWrite && ( )}
))}
)}
)} {/* 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) refreshTemplates() }} /> )} ) } function TemplateSection({ title, templates, expandedId, onToggle, deletingId, onDelete, canDelete, canEdit = false, canCustomize = false, canHide = false, onEdit, onCustomize, onHide, hidingId = null, 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 canHide?: boolean onEdit?: (template: BookingTemplateLibrary) => void onCustomize?: (template: BookingTemplateLibrary) => void onHide?: (template: BookingTemplateLibrary) => void hidingId?: string | null 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 && ( )} {canHide && onHide && ( )} {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') : ''}
)}
) })}
) }