Files
leocrm/frontend/src/components/documents/PrintTemplateEditor.tsx
T
Agent Zero b311ab7aa1
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(L1-L3): Dokumente-Generator — Briefpapier+Block-System+Drag&Drop-Editor+Renderer
- Briefpapier (letterheads): Seiten-Setup (A4/A5/Letter, Ränder), Header/Footer-Blöcke, Wasserzeichen, Logo-Upload (DocumentAsset, data:-URI-only)
- Druckvorlagen (print_templates): Block-Komposition mit Briefpapier-Ref + entity_type
- Block-Registry (document_blocks.py): text/image/shape(line/rect/circle)/table/spacer/divider/placeholder/pagebreak + Modul-Beiträge via document_blocks()-Contract
- Renderer (document_renderer.py): Blocks→HTML→PDF via WeasyPrint (SSRF-Sandbox data:-URI-only), @page-Frame mit running header/footer, Placeholder-Beispiel-Defaults gegen StrictUndefined
- Contract-Beitrag contacts: document_placeholders/document_data (#359-Muster wie importexport_entities)
- 13 neue Endpoints in documents.py: Letterhead-CRUD, Template-CRUD, Assets, document-blocks, document-placeholders, preview (HTML), render (PDF)
- Migration: Plugin-SQL 0003 (idempotent) + Alembic 0143 (Dual-Path, RLS fail-closed crm_api)
- Frontend: api/documents.ts, Settings→Dokumente (settings_pages), BlockEditor (@dnd-kit Palette/Canvas/Config/Live-Preview-iframe), LetterheadEditor, PrintTemplateEditor, DocumentGenerationDialog (global, ContactDetailPage-Integration)
- i18n de/en, api-documentation.md, plugin-development-guide.md, PROGRESS.md

Verifikation: 32/32 neue Tests + 9/9 Regressionen, tsc exit 0, Build OK 2.79s, Alembic-Fresh-DB 0143 mit RLS bewiesen, ruff clean
2026-08-29 09:49:16 +02:00

166 lines
6.2 KiB
TypeScript

/**
* PrintTemplateEditor — Druckvorlagen-Editor (Phase L2).
*
* Combines a letterhead reference, an entity type (selects the module's
* placeholder palette) and the drag&drop block composition (BlockEditor).
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useToast } from '@/components/ui/Toast';
import {
useCreatePrintTemplate,
useUpdatePrintTemplate,
useDocumentPlaceholders,
type DocBlock,
type Letterhead,
type PrintTemplate,
} from '@/api/documents';
import { BlockEditor } from './BlockEditor';
interface PrintTemplateEditorProps {
template: PrintTemplate | null;
letterheads: Letterhead[];
onClose: () => void;
}
export function PrintTemplateEditor({ template, letterheads, onClose }: PrintTemplateEditorProps) {
const { t } = useTranslation();
const toast = useToast();
const [name, setName] = useState(template?.name ?? '');
const [description, setDescription] = useState(template?.description ?? '');
const [letterheadId, setLetterheadId] = useState<string>(template?.letterhead_id ?? '');
const [entityType, setEntityType] = useState<string>(template?.entity_type ?? 'contact');
const [blocks, setBlocks] = useState<DocBlock[]>(template?.blocks ?? []);
const createMutation = useCreatePrintTemplate();
const updateMutation = useUpdatePrintTemplate();
// discover available entity types from the placeholder registry
const { data: placeholderMap } = useDocumentPlaceholders();
const entityTypes = useMemo(() => Object.keys(placeholderMap ?? {}).sort(), [placeholderMap]);
useEffect(() => {
if (template) {
setName(template.name);
setDescription(template.description);
setLetterheadId(template.letterhead_id ?? '');
setEntityType(template.entity_type);
setBlocks(template.blocks ?? []);
}
}, [template]);
const selectedLetterhead = useMemo(
() => letterheads.find((l) => l.id === letterheadId) ?? null,
[letterheads, letterheadId]
);
const handleSave = async () => {
if (!name.trim()) {
toast.error(t('documents.template.nameRequired', 'Name ist erforderlich'));
return;
}
const input = {
name: name.trim(),
description,
letterhead_id: letterheadId || null,
entity_type: entityType,
blocks,
output_format: 'pdf' as const,
};
try {
if (template) {
await updateMutation.mutateAsync({ id: template.id, input });
toast.success(t('documents.template.saved', 'Vorlage gespeichert'));
} else {
await createMutation.mutateAsync(input);
toast.success(t('documents.template.created', 'Vorlage angelegt'));
}
onClose();
} catch (e) {
toast.error(t('documents.template.saveFailed', 'Speichern fehlgeschlagen — Blöcke prüfen'));
}
};
return (
<Modal
open
onClose={onClose}
title={template ? t('documents.template.edit', 'Vorlage bearbeiten') : t('documents.template.create', 'Neue Druckvorlage')}
size="xl"
>
<div className="space-y-4" data-testid="print-template-editor">
{/* Meta */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="tpl-name">{t('common.name', 'Name')}</label>
<Input id="tpl-name" value={name} onChange={(e) => setName(e.target.value)} data-testid="template-name-input" />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="tpl-desc">{t('common.description', 'Beschreibung')}</label>
<Input id="tpl-desc" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="tpl-letterhead">{t('documents.letterhead', 'Briefpapier')}</label>
<select
id="tpl-letterhead"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm"
value={letterheadId}
onChange={(e) => setLetterheadId(e.target.value)}
data-testid="template-letterhead-select"
>
<option value="">{t('documents.template.noLetterhead', 'Ohne Briefpapier')}</option>
{letterheads.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
</div>
{/* Entity type */}
<div className="max-w-xs">
<label className="text-xs font-medium text-secondary-500" htmlFor="tpl-entity">{t('documents.template.entityType', 'Datenquelle (Modul)')}</label>
<select
id="tpl-entity"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm"
value={entityType}
onChange={(e) => setEntityType(e.target.value)}
data-testid="template-entity-type-select"
>
{(entityTypes.length > 0 ? entityTypes : ['contact']).map((et) => (
<option key={et} value={et}>{et}</option>
))}
</select>
<p className="text-[10px] text-secondary-400 mt-1">
{t('documents.template.entityTypeHint', 'Bestimmt die verfügbaren Platzhalter (Modul-Beitrag)')}
</p>
</div>
{/* Block editor (content blocks + letterhead frame preview) */}
<BlockEditor
blocks={blocks}
onChange={setBlocks}
entityType={entityType}
letterheadConfig={selectedLetterhead?.config ?? null}
/>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-secondary-100">
<Button variant="secondary" onClick={onClose}>{t('common.cancel', 'Abbrechen')}</Button>
<Button
onClick={handleSave}
isLoading={createMutation.isPending || updateMutation.isPending}
data-testid="template-save"
>
{t('common.save', 'Speichern')}
</Button>
</div>
</div>
</Modal>
);
}