feat(L1-L3): Dokumente-Generator — Briefpapier+Block-System+Drag&Drop-Editor+Renderer
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:
Agent Zero
2026-08-29 09:49:16 +02:00
parent fa429c3a88
commit b311ab7aa1
25 changed files with 4510 additions and 11 deletions
@@ -0,0 +1,530 @@
/**
* BlockEditor — drag&drop block editor (Phase L2).
*
* Shared canvas for letterhead (header/footer) and print template blocks:
* - Palette: builtin + module-contributed block types (from /document-blocks)
* - Sortable canvas (@dnd-kit): reorder, remove, select
* - Config panel: per-type fields (text content, shape params, table, image)
* - Live preview: debounced POST /documents/preview rendered into an iframe
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Trash2, Plus, Eye } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useDocumentBlockTypes, useDocumentPlaceholders, usePreviewDocument, type DocBlock } from '@/api/documents';
let blockIdCounter = 0;
function nextBlockId(): string {
blockIdCounter += 1;
return `blk_${Date.now().toString(36)}_${blockIdCounter}`;
}
function defaultConfigFor(type: string): Record<string, unknown> {
switch (type) {
case 'text':
return { content: '' };
case 'image':
return { asset_id: null, width: 200, align: 'left', alt: '' };
case 'shape':
return { shape: 'line', width: '100%', height: 2, color: '#111827' };
case 'table':
return { columns: [], rows: [], striped: true };
case 'spacer':
return { height: 24 };
case 'divider':
return { color: '#d1d5db', thickness: 1 };
case 'placeholder':
return { key: '', label: '' };
default:
return {};
}
}
interface SortableBlockProps {
block: DocBlock;
label: string;
selected: boolean;
onSelect: () => void;
onRemove: () => void;
}
function SortableBlock({ block, label, selected, onSelect, onRemove }: SortableBlockProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: block.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const summary = useMemo(() => {
const c = block.config ?? {};
switch (block.type) {
case 'text':
return typeof c.content === 'string' ? (c.content as string).slice(0, 60) : '';
case 'image':
return c.asset_id ? `Asset: ${String(c.asset_id).slice(0, 8)}` : 'Bild';
case 'shape':
return `Grafik: ${String(c.shape ?? '')}`;
case 'table':
return `Tabelle (${Array.isArray(c.rows) ? (c.rows as unknown[]).length : 0} Zeilen)`;
case 'spacer':
return `Abstand: ${String(c.height ?? 24)}px`;
case 'divider':
return 'Trennlinie';
case 'placeholder':
return c.key ? `{{${String(c.key)}}}` : 'Platzhalter';
case 'pagebreak':
return 'Seitenwechsel';
default:
return label;
}
}, [block, label]);
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
selected ? 'border-primary-400 bg-primary-50' : 'border-secondary-200 bg-white hover:bg-secondary-50'
}`}
data-testid={`block-editor-item-${block.id}`}
>
<button
className="cursor-grab touch-none text-secondary-400 hover:text-secondary-600"
aria-label="Block verschieben"
{...attributes}
{...listeners}
>
<GripVertical className="w-4 h-4" aria-hidden="true" />
</button>
<button className="flex-1 text-left min-w-0" onClick={onSelect} aria-label={`${label} auswählen`}>
<span className="text-xs font-medium text-secondary-500 uppercase">{label}</span>
<span className="block text-sm text-secondary-900 truncate">{summary || label}</span>
</button>
<button
onClick={onRemove}
className="text-secondary-400 hover:text-danger-600 p-1"
aria-label="Block entfernen"
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</button>
</div>
);
}
// ─── Config panels per block type ──────────────────────────────────────────
interface ConfigPanelProps {
block: DocBlock;
placeholders: { key: string; label: string; example: string }[];
onChange: (config: Record<string, unknown>) => void;
}
function ConfigPanel({ block, placeholders, onChange }: ConfigPanelProps) {
const { t } = useTranslation();
const c = block.config ?? {};
const set = (key: string, value: unknown) => onChange({ ...c, [key]: value });
if (block.type === 'text') {
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-text-content">
{t('documents.editor.content', 'Inhalt')} {t('documents.editor.placeholdersHint', '{{platzhalter}} möglich')}
</label>
<textarea
id="blk-text-content"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm min-h-[100px] focus:outline-none focus:ring-2 focus:ring-primary-500"
value={typeof c.content === 'string' ? c.content : ''}
onChange={(e) => set('content', e.target.value)}
data-testid="block-config-text-content"
/>
</div>
<div className="flex gap-2 items-center">
<label className="text-xs font-medium text-secondary-500 flex items-center gap-1">
<input type="checkbox" checked={!!c.bold} onChange={(e) => set('bold', e.target.checked)} />
{t('documents.editor.bold', 'Fett')}
</label>
<label className="text-xs font-medium text-secondary-500 flex items-center gap-1">
<input type="checkbox" checked={!!c.italic} onChange={(e) => set('italic', e.target.checked)} />
{t('documents.editor.italic', 'Kursiv')}
</label>
</div>
{placeholders.length > 0 && (
<div className="flex flex-wrap gap-1">
{placeholders.map((p) => (
<button
key={p.key}
type="button"
className="text-xs px-2 py-0.5 rounded bg-secondary-100 hover:bg-primary-100 text-secondary-700"
title={p.label}
onClick={() => set('content', `${typeof c.content === 'string' ? c.content : ''}{{${p.key}}}`)}
>
{`{{${p.key}}}`}
</button>
))}
</div>
)}
</div>
);
}
if (block.type === 'shape') {
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-shape">{t('documents.editor.shape', 'Form')}</label>
<select
id="blk-shape"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm"
value={String(c.shape ?? 'line')}
onChange={(e) => set('shape', e.target.value)}
>
<option value="line">{t('documents.editor.line', 'Linie')}</option>
<option value="rect">{t('documents.editor.rect', 'Rechteck')}</option>
<option value="circle">{t('documents.editor.circle', 'Kreis')}</option>
</select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-width">{t('documents.editor.width', 'Breite')}</label>
<Input id="blk-width" value={String(c.width ?? '100%')} onChange={(e) => set('width', e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-height">{t('documents.editor.height', 'Höhe (px)')}</label>
<Input
id="blk-height"
type="number"
value={String(c.height ?? 2)}
onChange={(e) => set('height', Number(e.target.value) || 1)}
/>
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-color">{t('documents.editor.color', 'Farbe')}</label>
<Input id="blk-color" value={String(c.color ?? '#111827')} onChange={(e) => set('color', e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-bg">{t('documents.editor.background', 'Füllung')}</label>
<Input id="blk-bg" value={String(c.background ?? '#e5e7eb')} onChange={(e) => set('background', e.target.value)} />
</div>
</div>
</div>
);
}
if (block.type === 'image') {
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-asset">{t('documents.editor.assetId', 'Asset-ID (Briefpapier-Upload)')}</label>
<Input id="blk-asset" value={String(c.asset_id ?? '')} onChange={(e) => set('asset_id', e.target.value)} placeholder="UUID" />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-img-width">{t('documents.editor.width', 'Breite (px)')}</label>
<Input id="blk-img-width" type="number" value={String(c.width ?? 200)} onChange={(e) => set('width', Number(e.target.value) || undefined)} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-img-alt">Alt</label>
<Input id="blk-img-alt" value={String(c.alt ?? '')} onChange={(e) => set('alt', e.target.value)} />
</div>
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-align">{t('documents.editor.align', 'Ausrichtung')}</label>
<select id="blk-align" className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm" value={String(c.align ?? 'left')} onChange={(e) => set('align', e.target.value)}>
<option value="left">{t('documents.editor.left', 'Links')}</option>
<option value="center">{t('documents.editor.center', 'Zentriert')}</option>
<option value="right">{t('documents.editor.right', 'Rechts')}</option>
</select>
</div>
</div>
);
}
if (block.type === 'table') {
const columns = Array.isArray(c.columns) ? (c.columns as string[]).join(', ') : '';
return (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-cols">{t('documents.editor.columns', 'Spalten (Komma-getrennt)')}</label>
<Input id="blk-cols" value={columns} onChange={(e) => set('columns', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-rows">{t('documents.editor.rows', 'Zeilen (JSON)')}</label>
<textarea
id="blk-rows"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm font-mono min-h-[80px]"
value={JSON.stringify(c.rows ?? [], null, 0)}
onChange={(e) => {
try {
set('rows', JSON.parse(e.target.value));
} catch {
/* ignore invalid JSON while typing */
}
}}
/>
</div>
</div>
);
}
if (block.type === 'spacer') {
return (
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-spacer">{t('documents.editor.height', 'Höhe (px)')}</label>
<Input id="blk-spacer" type="number" value={String(c.height ?? 24)} onChange={(e) => set('height', Number(e.target.value) || 24)} />
</div>
);
}
if (block.type === 'divider') {
return (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-div-color">{t('documents.editor.color', 'Farbe')}</label>
<Input id="blk-div-color" value={String(c.color ?? '#d1d5db')} onChange={(e) => set('color', e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-div-thick">{t('documents.editor.thickness', 'Dicke (px)')}</label>
<Input id="blk-div-thick" type="number" value={String(c.thickness ?? 1)} onChange={(e) => set('thickness', Number(e.target.value) || 1)} />
</div>
</div>
);
}
if (block.type === 'placeholder') {
return (
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-ph-key">{t('documents.editor.placeholderKey', 'Datenfeld')}</label>
<select
id="blk-ph-key"
className="mt-1 w-full rounded-md border border-secondary-300 px-3 py-2 text-sm"
value={String(c.key ?? '')}
onChange={(e) => {
const ph = placeholders.find((p) => p.key === e.target.value);
set('key', e.target.value);
if (ph && !c.label) set('label', ph.label);
}}
>
<option value=""></option>
{placeholders.map((p) => (
<option key={p.key} value={p.key}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="blk-ph-label">{t('documents.editor.label', 'Anzeige-Label')}</label>
<Input id="blk-ph-label" value={String(c.label ?? '')} onChange={(e) => set('label', e.target.value)} />
</div>
</div>
);
}
if (block.type === 'pagebreak') {
return <p className="text-xs text-secondary-500">{t('documents.editor.pagebreakHint', 'Erzwingt einen Seitenwechsel im PDF.')}</p>;
}
return <p className="text-xs text-secondary-500">{t('documents.editor.noConfig', 'Keine Konfiguration für diesen Block-Typ.')}</p>;
}
// ─── Main BlockEditor ─────────────────────────────────────────────────────
export interface BlockEditorProps {
blocks: DocBlock[];
onChange: (blocks: DocBlock[]) => void;
/** entity_type for placeholder palette (e.g. 'contact') */
entityType?: string | null;
/** full letterhead config for preview (null = no frame) */
letterheadConfig?: unknown;
/** preview data override (defaults to placeholder examples) */
previewData?: Record<string, unknown> | null;
}
export function BlockEditor({ blocks, onChange, entityType, letterheadConfig, previewData }: BlockEditorProps) {
const { t } = useTranslation();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [previewHtml, setPreviewHtml] = useState<string>('');
const { data: blockTypes = [] } = useDocumentBlockTypes();
const { data: placeholderMap } = useDocumentPlaceholders(entityType ?? undefined, !!entityType);
const preview = usePreviewDocument();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const placeholders = useMemo(() => {
if (!entityType || !placeholderMap) return [];
return placeholderMap[entityType] ?? [];
}, [entityType, placeholderMap]);
const selected = useMemo(() => blocks.find((b) => b.id === selectedId) ?? null, [blocks, selectedId]);
const selectedMeta = useMemo(() => blockTypes.find((b) => b.type === selected?.type) ?? null, [blockTypes, selected]);
const addBlock = (type: string) => {
const block: DocBlock = { id: nextBlockId(), type, config: defaultConfigFor(type) };
onChange([...blocks, block]);
setSelectedId(block.id);
};
const removeBlock = (id: string) => {
onChange(blocks.filter((b) => b.id !== id));
if (selectedId === id) setSelectedId(null);
};
const updateConfig = (id: string, config: Record<string, unknown>) => {
onChange(blocks.map((b) => (b.id === id ? { ...b, config } : b)));
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = blocks.findIndex((b) => b.id === active.id);
const newIndex = blocks.findIndex((b) => b.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
onChange(arrayMove(blocks, oldIndex, newIndex));
};
// debounced live preview
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = setTimeout(async () => {
try {
const res = await preview.mutateAsync({
blocks,
letterhead_config: (letterheadConfig as never) ?? null,
entity_type: entityType ?? null,
data: previewData ?? null,
});
setPreviewHtml(res.html);
} catch {
/* preview errors are non-fatal (e.g. invalid block while typing) */
}
}, 600);
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(blocks), entityType, JSON.stringify(letterheadConfig)]);
return (
<div className="grid grid-cols-12 gap-4" data-testid="block-editor">
{/* Palette */}
<div className="col-span-3 space-y-2" data-testid="block-editor-palette">
<h3 className="text-xs font-semibold text-secondary-500 uppercase">{t('documents.editor.palette', 'Blöcke')}</h3>
{['basis', 'grafik', 'layout', 'daten', 'modul'].map((cat) => {
const items = blockTypes.filter((b) => b.category === cat);
if (items.length === 0) return null;
return (
<div key={cat} className="space-y-1">
<p className="text-[10px] text-secondary-400 uppercase">{cat}</p>
{items.map((bt) => (
<button
key={bt.type}
onClick={() => addBlock(bt.type)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md border border-secondary-200 text-sm hover:bg-primary-50 hover:border-primary-300 transition-colors"
title={bt.description}
data-testid={`block-editor-add-${bt.type}`}
>
<Plus className="w-3.5 h-3.5 text-secondary-400" aria-hidden="true" />
{bt.label}
</button>
))}
</div>
);
})}
</div>
{/* Canvas */}
<div className="col-span-4 space-y-2" data-testid="block-editor-canvas">
<h3 className="text-xs font-semibold text-secondary-500 uppercase">{t('documents.editor.canvas', 'Reihenfolge')}</h3>
{blocks.length === 0 ? (
<p className="text-xs text-secondary-400 py-8 text-center border border-dashed border-secondary-200 rounded-lg">
{t('documents.editor.empty', 'Blöcke aus der Palette hinzufügen')}
</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{blocks.map((b) => {
const meta = blockTypes.find((t2) => t2.type === b.type);
return (
<SortableBlock
key={b.id}
block={b}
label={meta?.label ?? b.type}
selected={selectedId === b.id}
onSelect={() => setSelectedId(b.id)}
onRemove={() => removeBlock(b.id)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Config */}
<div className="col-span-5 space-y-2" data-testid="block-editor-config">
<h3 className="text-xs font-semibold text-secondary-500 uppercase flex items-center gap-1">
<Eye className="w-3 h-3" aria-hidden="true" />
{t('documents.editor.configPreview', 'Konfiguration & Vorschau')}
</h3>
{selected ? (
<div className="border border-secondary-200 rounded-lg p-3 bg-white space-y-3">
<p className="text-sm font-medium text-secondary-900">{selectedMeta?.label ?? selected.type}</p>
<ConfigPanel
block={selected}
placeholders={placeholders}
onChange={(config) => updateConfig(selected.id, config)}
/>
</div>
) : (
<p className="text-xs text-secondary-400 border border-dashed border-secondary-200 rounded-lg p-3">
{t('documents.editor.selectBlock', 'Block in der Reihenfolge-Liste auswählen, um ihn zu konfigurieren.')}
</p>
)}
<div className="border border-secondary-200 rounded-lg overflow-hidden bg-white">
<iframe
title={t('documents.editor.preview', 'Live-Vorschau')}
srcDoc={previewHtml}
className="w-full h-[420px] bg-white"
data-testid="block-editor-preview"
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
/**
* DocumentGenerationDialog — global document generation dialog (Phase L2).
*
* Any module can open this dialog with an entityType + entityId to render
* a print template to PDF. The dialog lists the tenant's templates filtered
* by entity_type, triggers /print-templates/{id}/render and downloads the
* resulting blob.
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FileDown, Loader2 } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import {
usePrintTemplates,
useRenderPrintTemplate,
type PrintTemplate,
} from '@/api/documents';
export interface DocumentGenerationDialogProps {
open: boolean;
onClose: () => void;
/** entity type for template filtering + render (e.g. 'contact', 'company') */
entityType: string;
/** entity to render (data source via module contribution) */
entityId: string;
/** optional display name for the header */
entityLabel?: string;
}
export function DocumentGenerationDialog({
open,
onClose,
entityType,
entityId,
entityLabel,
}: DocumentGenerationDialogProps) {
const { t } = useTranslation();
const toast = useToast();
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data: templates = [], isLoading } = usePrintTemplates();
const render = useRenderPrintTemplate();
// templates matching the entity type (exact match wins; 'contact' as
// generic fallback so contact/person/company templates are all offered)
const matching = useMemo(() => {
const exact = templates.filter((tpl) => tpl.entity_type === entityType);
if (exact.length > 0) return exact;
if (entityType === 'company' || entityType === 'person') {
return templates.filter((tpl) => tpl.entity_type === 'contact');
}
return [];
}, [templates, entityType]);
const selected = matching.find((tpl) => tpl.id === selectedId) ?? null;
const handleGenerate = async (template: PrintTemplate) => {
try {
const blob = await render.mutateAsync({
templateId: template.id,
entityType,
entityId,
});
// trigger browser download
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${template.name.replace(/\s+/g, '_')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success(t('documents.dialog.generated', 'Dokument wurde erstellt'));
onClose();
} catch (e) {
const message = e instanceof Error ? e.message : '';
toast.error(t('documents.dialog.generateFailed', 'Dokument konnte nicht erstellt werden'));
if (message) console.warn('[DocumentGenerationDialog]', message);
}
};
return (
<Modal
open={open}
onClose={onClose}
title={t('documents.dialog.title', 'Dokument erstellen')}
size="md"
>
<div className="space-y-4" data-testid="document-generation-dialog">
{entityLabel && (
<p className="text-sm text-secondary-500">
{t('documents.dialog.for', 'Für')}: <span className="font-medium text-secondary-900">{entityLabel}</span>
</p>
)}
{isLoading ? (
<div className="flex items-center justify-center py-8" role="status">
<Loader2 className="animate-spin h-6 w-6 text-primary-500" aria-hidden="true" />
</div>
) : matching.length === 0 ? (
<p className="text-sm text-secondary-500 py-6 text-center">
{t('documents.dialog.noTemplates', 'Keine Druckvorlagen für diesen Datentyp. Vorlagen unter Einstellungen → Dokumente anlegen.')}
</p>
) : (
<ul className="divide-y divide-secondary-100" data-testid="document-dialog-template-list">
{matching.map((tpl) => (
<li key={tpl.id} className="flex items-center justify-between py-3 gap-3">
<div className="min-w-0">
<p className="font-medium text-secondary-900 truncate">{tpl.name}</p>
<p className="text-xs text-secondary-500 truncate">{tpl.description || '—'}</p>
</div>
<Button
size="sm"
icon={<FileDown className="w-4 h-4" aria-hidden="true" />}
onClick={() => handleGenerate(tpl)}
isLoading={render.isPending && render.variables?.templateId === tpl.id}
data-testid={`document-dialog-generate-${tpl.id}`}
>
{t('documents.dialog.generate', 'Erstellen')}
</Button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,291 @@
/**
* LetterheadEditor — Briefpapier-Editor (Phase L2).
*
* Page setup (size/orientation/margins) + header/footer block composition
* (drag&drop via BlockEditor) + logo upload (DocumentAsset).
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Upload } from 'lucide-react';
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 { Skeleton } from '@/components/ui/Skeleton';
import {
useCreateLetterhead,
useUpdateLetterhead,
useLetterheadAssets,
useUploadLetterheadAsset,
type DocBlock,
type Letterhead,
type LetterheadConfig,
} from '@/api/documents';
import { BlockEditor } from './BlockEditor';
interface LetterheadEditorProps {
letterhead: Letterhead | null;
onClose: () => void;
}
const DEFAULT_CONFIG: LetterheadConfig = {
page: { size: 'A4', orientation: 'portrait', margins: { top: 25, right: 20, bottom: 25, left: 20 } },
header: { enabled: true, blocks: [] },
footer: { enabled: false, blocks: [] },
watermark: { enabled: false, text: '' },
};
export function LetterheadEditor({ letterhead, onClose }: LetterheadEditorProps) {
const { t } = useTranslation();
const toast = useToast();
const [name, setName] = useState(letterhead?.name ?? '');
const [description, setDescription] = useState(letterhead?.description ?? '');
const [isDefault, setIsDefault] = useState(letterhead?.is_default ?? false);
const [config, setConfig] = useState<LetterheadConfig>(letterhead?.config ?? DEFAULT_CONFIG);
const [section, setSection] = useState<'header' | 'footer'>('header');
const createMutation = useCreateLetterhead();
const updateMutation = useUpdateLetterhead();
const uploadAsset = useUploadLetterheadAsset();
const { data: assets = [], isLoading: assetsLoading } = useLetterheadAssets(letterhead?.id ?? null);
useEffect(() => {
if (letterhead) {
setName(letterhead.name);
setDescription(letterhead.description);
setIsDefault(letterhead.is_default);
setConfig({ ...DEFAULT_CONFIG, ...letterhead.config });
}
}, [letterhead]);
const setSectionBlocks = (blocks: DocBlock[]) => {
setConfig((c) => ({ ...c, [section]: { ...(c[section] ?? { enabled: true, blocks: [] }), blocks } }));
};
const handleSave = async () => {
if (!name.trim()) {
toast.error(t('documents.letterhead.nameRequired', 'Name ist erforderlich'));
return;
}
const input = { name: name.trim(), description, config, is_default: isDefault };
try {
if (letterhead) {
await updateMutation.mutateAsync({ id: letterhead.id, input });
toast.success(t('documents.letterhead.saved', 'Briefpapier gespeichert'));
} else {
const created = await createMutation.mutateAsync(input);
toast.success(t('documents.letterhead.created', 'Briefpapier angelegt'));
// upload pending logo files after create? (uploads happen via assets panel below)
void created;
}
onClose();
} catch {
toast.error(t('common.error', 'Speichern fehlgeschlagen'));
}
};
const page = config.page ?? { size: 'A4', orientation: 'portrait', margins: { top: 25, right: 20, bottom: 25, left: 20 } };
const margins = page.margins ?? { top: 25, right: 20, bottom: 25, left: 20 };
return (
<Modal
open
onClose={onClose}
title={letterhead ? t('documents.letterhead.edit', 'Briefpapier bearbeiten') : t('documents.letterhead.create', 'Neues Briefpapier')}
size="xl"
>
<div className="space-y-4" data-testid="letterhead-editor">
{/* Meta */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="lh-name">{t('common.name', 'Name')}</label>
<Input id="lh-name" value={name} onChange={(e) => setName(e.target.value)} data-testid="letterhead-name-input" />
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="lh-desc">{t('common.description', 'Beschreibung')}</label>
<Input id="lh-desc" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
</div>
<label className="flex items-center gap-2 text-sm text-secondary-700">
<input
type="checkbox"
checked={isDefault}
onChange={(e) => setIsDefault(e.target.checked)}
data-testid="letterhead-default-checkbox"
/>
{t('documents.letterhead.isDefault', 'Als Standard-Briefpapier verwenden')}
</label>
{/* Page setup */}
<div className="grid grid-cols-4 gap-3 border border-secondary-200 rounded-lg p-3">
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="lh-size">{t('documents.page.size', 'Seitenformat')}</label>
<select
id="lh-size"
className="mt-1 w-full rounded-md border border-secondary-300 px-2 py-1.5 text-sm"
value={page.size ?? 'A4'}
onChange={(e) => setConfig((c) => ({ ...c, page: { ...(c.page ?? {}), size: e.target.value } }))}
>
<option value="A4">A4</option>
<option value="A5">A5</option>
<option value="letter">Letter</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary-500" htmlFor="lh-orient">{t('documents.page.orientation', 'Ausrichtung')}</label>
<select
id="lh-orient"
className="mt-1 w-full rounded-md border border-secondary-300 px-2 py-1.5 text-sm"
value={page.orientation ?? 'portrait'}
onChange={(e) => setConfig((c) => ({ ...c, page: { ...(c.page ?? {}), orientation: e.target.value } }))}
>
<option value="portrait">{t('documents.page.portrait', 'Hochformat')}</option>
<option value="landscape">{t('documents.page.landscape', 'Querformat')}</option>
</select>
</div>
<div className="col-span-2">
<p className="text-xs font-medium text-secondary-500 mb-1">{t('documents.page.margins', 'Seitenränder (mm)')}</p>
<div className="grid grid-cols-4 gap-2">
{(['top', 'right', 'bottom', 'left'] as const).map((side) => (
<div key={side}>
<label className="text-[10px] text-secondary-400" htmlFor={`lh-margin-${side}`}>{side}</label>
<Input
id={`lh-margin-${side}`}
type="number"
value={margins[side] ?? 20}
onChange={(e) =>
setConfig((c) => ({
...c,
page: {
...(c.page ?? {}),
margins: { ...(c.page?.margins ?? {}), [side]: Number(e.target.value) || 0 },
},
}))
}
/>
</div>
))}
</div>
</div>
</div>
{/* Watermark */}
<div className="flex items-center gap-3 border border-secondary-200 rounded-lg p-3">
<label className="flex items-center gap-2 text-sm text-secondary-700">
<input
type="checkbox"
checked={config.watermark?.enabled ?? false}
onChange={(e) => setConfig((c) => ({ ...c, watermark: { enabled: e.target.checked, text: c.watermark?.text ?? '' } }))}
/>
{t('documents.watermark', 'Wasserzeichen')}
</label>
{config.watermark?.enabled && (
<Input
value={config.watermark.text ?? ''}
onChange={(e) => setConfig((c) => ({ ...c, watermark: { enabled: true, text: e.target.value } }))}
placeholder={t('documents.watermarkText', 'z.B. ENTWURF')}
aria-label={t('documents.watermarkText', 'Wasserzeichen-Text')}
/>
)}
</div>
{/* Logo/Asset upload */}
<div className="border border-secondary-200 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-secondary-500 uppercase">{t('documents.assets', 'Bilder & Logos')}</p>
{letterhead && (
<label className="cursor-pointer text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1" data-testid="letterhead-asset-upload">
<Upload className="w-4 h-4" aria-hidden="true" />
{t('documents.asset.upload', 'Bild hochladen')}
<input
type="file"
accept="image/png,image/jpeg,image/gif,image/svg+xml,image/webp"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const asset = await uploadAsset.mutateAsync({ letterheadId: letterhead.id, file });
toast.success(t('documents.asset.uploaded', 'Bild hochgeladen (ID in Zwischenablage)'));
await navigator.clipboard?.writeText(asset.id).catch(() => undefined);
} catch {
toast.error(t('documents.asset.uploadFailed', 'Upload fehlgeschlagen'));
}
e.target.value = '';
}}
/>
</label>
)}
</div>
{!letterhead && (
<p className="text-xs text-secondary-400">{t('documents.asset.saveFirst', 'Briefpapier erst speichern, dann Bilder hochladen.')}</p>
)}
{letterhead && assetsLoading && <Skeleton className="h-8 w-full" />}
{letterhead && !assetsLoading && assets.length === 0 && (
<p className="text-xs text-secondary-400">{t('documents.asset.empty', 'Noch keine Bilder.')}</p>
)}
{assets.length > 0 && (
<ul className="space-y-1">
{assets.map((a) => (
<li key={a.id} className="flex items-center gap-2 text-xs">
{a.data_url && <img src={a.data_url} alt={a.filename} className="w-6 h-6 object-contain" />}
<span className="truncate">{a.filename}</span>
<code className="text-secondary-400 truncate flex-1" title={a.id}>{a.id.slice(0, 8)}</code>
<span className="text-secondary-400">{(a.size_bytes / 1024).toFixed(0)} KB</span>
</li>
))}
</ul>
)}
</div>
{/* Header/Footer block editors */}
<div className="flex gap-2" role="tablist" aria-label="Briefpapier-Bereiche">
{(['header', 'footer'] as const).map((sec) => (
<button
key={sec}
role="tab"
aria-selected={section === sec}
onClick={() => setSection(sec)}
className={`px-3 py-1.5 rounded-md text-sm font-medium ${
section === sec ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100'
}`}
>
{sec === 'header' ? t('documents.header', 'Kopfzeile') : t('documents.footer', 'Fußzeile')}
{' '}
<label className="ml-2 inline-flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={config[sec]?.enabled ?? false}
onChange={(e) =>
setConfig((c) => ({ ...c, [sec]: { ...(c[sec] ?? { blocks: [] }), enabled: e.target.checked } }))
}
aria-label={sec === 'header' ? t('documents.header', 'Kopfzeile') : t('documents.footer', 'Fußzeile')}
/>
{t('common.active', 'aktiv')}
</label>
</button>
))}
</div>
{config[section]?.enabled && (
<BlockEditor
blocks={config[section]?.blocks ?? []}
onChange={setSectionBlocks}
letterheadConfig={config}
/>
)}
{/* 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="letterhead-save">
{t('common.save', 'Speichern')}
</Button>
</div>
</div>
</Modal>
);
}
@@ -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>
);
}
@@ -128,6 +128,7 @@ const STATIC_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
'@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })),
'@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })),
'@/pages/DedupMergePage': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })),
'@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').then(normalizeModule),
'@/pages/Dms': () => import('@/pages/Dms').then(normalizeModule),
'@/pages/DmsTrash': () => import('@/pages/DmsTrash').then(normalizeModule),
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then(normalizeModule),