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,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),
|
||||
|
||||
Reference in New Issue
Block a user