feat(L1-L3): Dokumente-Generator — Briefpapier+Block-System+Drag&Drop-Editor+Renderer
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- 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
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user