import { Download, Eye, FileCheck2, Plus, RefreshCw, Save, Send, Trash2, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { DialogSection, ToolbarGroup, ActionToolbar, ApiError, ActionBlockerHint, Button, ConfirmDialog, ContentSection, Dialog, DocumentationHelpLink, DismissibleAlert, FilterBar, FormField, IconButton, LoadingFrame, SegmentedControl, SelectionList, SelectionListItem, SelectionListItemContent, StatePanel, StatusBadge, ToggleSwitch, WorkspaceFrame, WorkspaceLayout, formatDateTime, hasScope, useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; import { WysiwygEditor } from "@govoplan/core-webui/wysiwyg"; import { checkTemplateCompatibility, createTemplate, deleteTemplate, downloadTemplateRender, listTemplateRenders, listTemplateRevisions, listTemplates, publishTemplate, renderTemplate, updateTemplate, type TemplateCompatibility, type TemplateDefinition, type TemplateFieldRequirement, type TemplateFieldType, type TemplatePayload, type TemplateRender, type TemplateRevision, type TemplateType } from "../../api/templates"; import { TEMPLATE_FIELDS_DOCUMENTATION, TEMPLATE_OUTPUT_DOCUMENTATION, TEMPLATES_DOCUMENTATION, TEMPLATES_I18N } from "./interfacePatterns"; type Props = { settings: ApiSettings; auth: AuthInfo }; type WorkspaceView = "definition" | "preview"; const TEMPLATE_TYPES: Array<{ value: TemplateType; label: string }> = [ { value: "label", label: "Label" }, { value: "label_sheet", label: "Label sheet" }, { value: "envelope", label: "Envelope" }, { value: "serial_letter", label: "Serial letter" }, { value: "form_letter", label: "Form letter" }, { value: "list_layout", label: "List layout" }, { value: "email", label: "Email" }, { value: "content_fragment", label: "Content fragment" }, { value: "generic", label: "Generic" } ]; const FIELD_TYPES: TemplateFieldType[] = [ "string", "integer", "number", "boolean", "date", "datetime", "object", "array" ]; export default function TemplatesPage({ settings, auth }: Props) { const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(""); const [draft, setDraft] = useState(emptyPayload()); const [savedKey, setSavedKey] = useState(""); const [view, setView] = useState("definition"); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [createName, setCreateName] = useState(""); const [createType, setCreateType] = useState("form_letter"); const [deleteOpen, setDeleteOpen] = useState(false); const [publishOpen, setPublishOpen] = useState(false); const [finalRenderOpen, setFinalRenderOpen] = useState(false); const [sampleText, setSampleText] = useState('{\n "name": "Ada Example",\n "address": "Main Street 1",\n "postal_code": "10115",\n "city": "Berlin"\n}'); const [usage, setUsage] = useState("campaign.postal"); const [outputFormat, setOutputFormat] = useState<"html" | "text">("html"); const [persistToFiles, setPersistToFiles] = useState(false); const [compatibility, setCompatibility] = useState(null); const [render, setRender] = useState(null); const [revisions, setRevisions] = useState([]); const [renders, setRenders] = useState([]); const { requestDiscard } = useUnsavedChanges(); const selected = items.find((item) => item.id === selectedId) ?? null; const canWrite = hasScope(auth, "templates:template:write") || hasScope(auth, "templates:template:admin"); const canPublish = hasScope(auth, "templates:template:publish") || hasScope(auth, "templates:template:admin"); const canRender = hasScope(auth, "templates:template:render") || hasScope(auth, "templates:template:admin"); const dirty = Boolean(selected && draftKey(draft) !== savedKey); const readOnly = !canWrite || Boolean(selected?.read_only); const applyItem = useCallback((item: TemplateDefinition | null) => { const next = item ? payloadFromItem(item) : emptyPayload(); setDraft(next); setSavedKey(item ? draftKey(next) : ""); setCompatibility(null); setRender(null); setUsage(item?.revision.usages[0] ?? "campaign.postal"); }, []); const reload = useCallback(async (preferredId?: string) => { setLoading(true); setError(""); try { const nextItems = await listTemplates(settings); setItems(nextItems); const nextId = preferredId && nextItems.some((item) => item.id === preferredId) ? preferredId : nextItems.some((item) => item.id === selectedId) ? selectedId : nextItems[0]?.id ?? ""; setSelectedId(nextId); applyItem(nextItems.find((item) => item.id === nextId) ?? null); } catch (caught) { setError(errorMessage(caught)); } finally { setLoading(false); } }, [applyItem, selectedId, settings]); useEffect(() => { void reload(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => { if (!selectedId) { setRevisions([]); setRenders([]); return; } let active = true; void Promise.all([ listTemplateRevisions(settings, selectedId), listTemplateRenders(settings, selectedId) ]).then(([nextRevisions, nextRenders]) => { if (!active) return; setRevisions(nextRevisions); setRenders(nextRenders); }).catch((caught) => { if (active) setError(errorMessage(caught)); }); return () => { active = false; }; }, [selectedId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); const visibleItems = useMemo(() => { const needle = search.trim().toLocaleLowerCase(); return needle ? items.filter((item) => `${item.name} ${item.template_type} ${item.revision.usages.join(" ")}`.toLocaleLowerCase().includes(needle)) : items; }, [items, search]); const save = async () => { if (!selected || !draft.name.trim() || !draft.usages.length) return false; setBusy(true); setError(""); try { const updated = await updateTemplate(settings, selected, draft); setSuccess(`Saved immutable revision ${updated.current_revision}.`); await reload(updated.id); return true; } catch (caught) { setError(errorMessage(caught)); return false; } finally { setBusy(false); } }; useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: () => applyItem(selected), title: "i18n:govoplan-templates.unsaved_title", message: "i18n:govoplan-templates.unsaved_message" }); const create = async (): Promise => { if (!createName.trim()) return false; setBusy(true); setError(""); try { const created = await createTemplate(settings, { ...emptyPayload(createType), name: createName.trim() }); setCreateOpen(false); setCreateName(""); setSuccess(`Created ${created.name}.`); await reload(created.id); return true; } catch (caught) { setError(errorMessage(caught)); return false; } finally { setBusy(false); } }; useUnsavedDraftGuard({ dirty: Boolean(createOpen && createName.trim()), onSave: create, onDiscard: () => { setCreateOpen(false); setCreateName(""); setCreateType("form_letter"); }, title: "i18n:govoplan-templates.create_unsaved_title", message: "i18n:govoplan-templates.create_unsaved_message" }); const closeCreate = () => { if (busy) return; if (createName.trim()) requestDiscard(() => setCreateOpen(false)); else setCreateOpen(false); }; const publish = async () => { if (!selected || dirty) return; setBusy(true); try { const updated = await publishTemplate(settings, selected); setPublishOpen(false); setSuccess(`Published revision ${updated.current_revision}.`); await reload(updated.id); } catch (caught) { setError(errorMessage(caught)); } finally { setBusy(false); } }; const remove = async () => { if (!selected) return; setBusy(true); try { await deleteTemplate(settings, selected); setDeleteOpen(false); setSuccess(`Deleted ${selected.name}.`); await reload(); } catch (caught) { setError(errorMessage(caught)); } finally { setBusy(false); } }; const runRender = async (final: boolean) => { if (!selected || dirty) return; setBusy(true); setError(""); try { const sample = parseSample(sampleText); const fields = flattenFieldTypes(sample); const nextCompatibility = await checkTemplateCompatibility(settings, selected, usage, fields, outputFormat); setCompatibility(nextCompatibility); if (!nextCompatibility.compatible) return; const nextRender = await renderTemplate(settings, selected, { usage, outputFormat, items: [sample], final, persistToFiles }); setRender(nextRender); if (final) setFinalRenderOpen(false); setRenders(await listTemplateRenders(settings, selected.id)); setSuccess(`${final ? "Final" : "Preview"} output rendered with ${nextRender.item_count} item(s).`); } catch (caught) { setError(errorMessage(caught)); } finally { setBusy(false); } }; return ( Template library } variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} /> setSearch(event.target.value)} placeholder="Search templates" /> {visibleItems.map((item) => ( { if (item.id === selectedId) return; requestDiscard(() => { setSelectedId(item.id); applyItem(item); }); }} > ))} {!visibleItems.length && } } > {selected?.name ?? "Select a template"} {selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""} } disabled={loading || busy} disabledReason={loading ? TEMPLATES_I18N.loading : busy ? TEMPLATES_I18N.busy : undefined} onClick={() => requestDiscard(() => void reload(selectedId))} /> } variant="danger" disabled={!selected || readOnly} disabledReason={!selected ? TEMPLATES_I18N.noSelection : readOnly ? (canWrite ? TEMPLATES_I18N.readOnlyReason : TEMPLATES_I18N.writeReason) : undefined} onClick={() => setDeleteOpen(true)} />
{error && {error}} {success && {success}} {selected && readOnly && }
{!selected ? : view === "definition" ? <> : <> final ? setFinalRenderOpen(true) : void runRender(false)} onDownload={() => render && void downloadTemplateRender(settings, render).catch((caught) => setError(errorMessage(caught)))} /> }
}> setCreateName(event.target.value)} /> setPublishOpen(false)} onConfirm={() => void publish()} /> setFinalRenderOpen(false)} onConfirm={() => void runRender(true)} /> setDeleteOpen(false)} onConfirm={() => void remove()} />
); } function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) { return Revision history{revisions.length} immutable revision(s)
{revisions.map((revision) =>
Revision {revision.revision}{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)} {revision.id === currentRevisionId && } {revision.id === publishedRevisionId && }
)} {!revisions.length && }
; } function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) { return Output history{renders.length} recent render(s)
{renders.map((item) =>
{item.filename}{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}
)} {!renders.length && }
; } function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) { const update = (key: K, value: TemplatePayload[K]) => onChange({ ...draft, [key]: value }); const scopeOptions = [ { value: "tenant:", label: "Tenant" }, { value: `user:${auth.user.account_id}`, label: "Only me" }, ...auth.groups.map((group) => ({ value: `group:${group.id}`, label: `Group: ${group.name}` })) ]; const scopeValue = `${draft.scope_type}:${draft.scope_id ?? ""}`; const layout = draft.layout; return (
update("name", event.target.value)} /> update("locale", event.target.value)} /> update("usages", splitValues(event.target.value))} /> update("description", event.target.value || null)} />
Required data contract
{draft.required_fields.map((field, index) => (
updateField(draft, index, { path: event.target.value }, onChange)} /> updateField(draft, index, { label: event.target.value || null }, onChange)} /> updateField(draft, index, { required: checked }, onChange)} /> } variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} />
))} {!draft.required_fields.length && }
Page and media
update("layout", { ...layout, margin_mm: Number(event.target.value) })} /> {draft.template_type === "label_sheet" && <> update("layout", { ...layout, columns: Number(event.target.value) })} /> update("layout", { ...layout, rows: Number(event.target.value) })} /> update("layout", { ...layout, gap_mm: Number(event.target.value) })} /> }
Template bodyUse tokens such as {"{{name}}"} or {"{{recipient.address}}"}. update("content_html", value || null)} minHeight={300} />
); } function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, compatibility, render, disabled, disabledReason, onSampleText, onUsage, onOutputFormat, onPersistToFiles, onRender, onDownload }: { item: TemplateDefinition; sampleText: string; usage: string; outputFormat: "html" | "text"; persistToFiles: boolean; compatibility: TemplateCompatibility | null; render: TemplateRender | null; disabled: boolean; disabledReason?: string; onSampleText: (value: string) => void; onUsage: (value: string) => void; onOutputFormat: (value: "html" | "text") => void; onPersistToFiles: (value: boolean) => void; onRender: (final: boolean) => void; onDownload: () => void; }) { return (
Validated sample inputPreview and final output use the same pinned revision and canonical input.