b311ab7aa1
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
166 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|