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
+296
View File
@@ -0,0 +1,296 @@
/**
* Documents Generator API client (Phase L1-L3).
*
* Letterheads (Briefpapier), print templates (block compositions),
* block registry and placeholders — all under /reports/... (documents
* router of the report_generator plugin).
*/
import { apiClient, apiDelete, apiGet, apiPost, apiPut } from './client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
// ─── Types ─────────────────────────────────────────────────────────────────
export interface DocBlock {
id: string;
type: string;
config: Record<string, unknown>;
}
export interface LetterheadPageConfig {
size?: string;
orientation?: string;
margins?: { top?: number; right?: number; bottom?: number; left?: number };
}
export interface LetterheadConfig {
page?: LetterheadPageConfig;
header?: { enabled: boolean; blocks: DocBlock[] };
footer?: { enabled: boolean; blocks: DocBlock[] };
watermark?: { enabled: boolean; text?: string };
}
export interface Letterhead {
id: string;
name: string;
description: string;
config: LetterheadConfig;
is_default: boolean;
created_by: string;
created_at?: string | null;
updated_at?: string | null;
}
export interface PrintTemplate {
id: string;
name: string;
description: string;
letterhead_id: string | null;
entity_type: string;
blocks: DocBlock[];
output_format: string;
created_by: string;
created_at?: string | null;
updated_at?: string | null;
}
export interface DocumentBlockType {
type: string;
label: string;
category: string;
description: string;
fields: Record<string, string>;
builtin: boolean;
contributed_by?: string | null;
}
export interface DocumentPlaceholder {
key: string;
label: string;
example: string;
}
export interface DocumentAsset {
id: string;
letterhead_id: string | null;
filename: string;
mime_type: string;
size_bytes: number;
data_url: string | null;
created_at?: string | null;
}
export interface LetterheadInput {
name: string;
description?: string;
config?: LetterheadConfig;
is_default?: boolean;
}
export interface PrintTemplateInput {
name: string;
description?: string;
letterhead_id?: string | null;
entity_type?: string;
blocks?: DocBlock[];
output_format?: string;
}
// ─── Query Keys ─────────────────────────────────────────────────────────────
export const DOC_QUERY_KEYS = {
letterheads: ['documents', 'letterheads'] as const,
letterhead: (id: string) => ['documents', 'letterheads', id] as const,
templates: ['documents', 'print-templates'] as const,
template: (id: string) => ['documents', 'print-templates', id] as const,
blocks: ['documents', 'block-types'] as const,
placeholders: (entityType?: string) => ['documents', 'placeholders', entityType ?? 'all'] as const,
assets: (letterheadId: string) => ['documents', 'assets', letterheadId] as const,
};
// ─── Fetch helpers ─────────────────────────────────────────────────────────
async function fetchLetterheads(): Promise<Letterhead[]> {
const res = await apiGet<{ items: Letterhead[]; total: number }>('/reports/letterheads');
return res.items ?? [];
}
async function fetchTemplates(): Promise<PrintTemplate[]> {
const res = await apiGet<{ items: PrintTemplate[]; total: number }>('/reports/print-templates');
return res.items ?? [];
}
async function fetchBlockTypes(): Promise<DocumentBlockType[]> {
return apiGet<DocumentBlockType[]>('/reports/document-blocks');
}
async function fetchPlaceholders(entityType?: string): Promise<Record<string, DocumentPlaceholder[]>> {
const url = entityType
? `/reports/document-placeholders?entity_type=${encodeURIComponent(entityType)}`
: '/reports/document-placeholders';
return apiGet<Record<string, DocumentPlaceholder[]>>(url);
}
async function fetchAssets(letterheadId: string): Promise<DocumentAsset[]> {
return apiGet<DocumentAsset[]>(`/reports/letterheads/${letterheadId}/assets`);
}
// ─── Hooks: Letterheads ─────────────────────────────────────────────────────
export function useLetterheads() {
return useQuery<Letterhead[]>({
queryKey: DOC_QUERY_KEYS.letterheads,
queryFn: fetchLetterheads,
});
}
export function useCreateLetterhead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: LetterheadInput) =>
apiPost<Letterhead>('/reports/letterheads', input),
onSuccess: () => qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.letterheads }),
});
}
export function useUpdateLetterhead() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: Partial<LetterheadInput> }) =>
apiPut<Letterhead>(`/reports/letterheads/${id}`, input),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.letterheads });
qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.letterhead(vars.id) });
},
});
}
export function useDeleteLetterhead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/reports/letterheads/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.letterheads }),
});
}
// ─── Hooks: Print Templates ─────────────────────────────────────────────────
export function usePrintTemplates() {
return useQuery<PrintTemplate[]>({
queryKey: DOC_QUERY_KEYS.templates,
queryFn: fetchTemplates,
});
}
export function useCreatePrintTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: PrintTemplateInput) =>
apiPost<PrintTemplate>('/reports/print-templates', input),
onSuccess: () => qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.templates }),
});
}
export function useUpdatePrintTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: Partial<PrintTemplateInput> }) =>
apiPut<PrintTemplate>(`/reports/print-templates/${id}`, input),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.templates });
qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.template(vars.id) });
},
});
}
export function useDeletePrintTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/reports/print-templates/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.templates }),
});
}
// ─── Hooks: Registry ────────────────────────────────────────────────────────
export function useDocumentBlockTypes() {
return useQuery<DocumentBlockType[]>({
queryKey: DOC_QUERY_KEYS.blocks,
queryFn: fetchBlockTypes,
staleTime: 5 * 60 * 1000,
});
}
export function useDocumentPlaceholders(entityType?: string, enabled = true) {
return useQuery<Record<string, DocumentPlaceholder[]>>({
queryKey: DOC_QUERY_KEYS.placeholders(entityType),
queryFn: () => fetchPlaceholders(entityType),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useLetterheadAssets(letterheadId: string | null | undefined) {
return useQuery<DocumentAsset[]>({
queryKey: DOC_QUERY_KEYS.assets(letterheadId ?? 'none'),
queryFn: () => fetchAssets(letterheadId as string),
enabled: !!letterheadId,
});
}
export function useUploadLetterheadAsset() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ letterheadId, file }: { letterheadId: string; file: File }) => {
const form = new FormData();
form.append('file', file);
return apiClient
.post(`/reports/letterheads/${letterheadId}/assets`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
.then((r) => r.data as DocumentAsset);
},
onSuccess: (_data, vars) =>
qc.invalidateQueries({ queryKey: DOC_QUERY_KEYS.assets(vars.letterheadId) }),
});
}
// ─── Preview & Render ──────────────────────────────────────────────────────
export interface PreviewInput {
blocks: DocBlock[];
letterhead_config: LetterheadConfig | null;
entity_type?: string | null;
data?: Record<string, unknown> | null;
}
export function usePreviewDocument() {
return useMutation({
mutationFn: (input: PreviewInput) =>
apiPost<{ html: string }>('/reports/documents/preview', input),
});
}
export interface RenderInput {
templateId: string;
entityType: string;
entityId: string;
outputFormat?: string;
}
export async function renderPrintTemplate(input: RenderInput): Promise<Blob> {
const response = await apiClient.post(
`/reports/print-templates/${input.templateId}/render`,
{
entity_type: input.entityType,
entity_id: input.entityId,
output_format: input.outputFormat ?? 'pdf',
},
{ responseType: 'blob' },
);
return response.data as Blob;
}
export function useRenderPrintTemplate() {
return useMutation({ mutationFn: renderPrintTemplate });
}
@@ -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),
+95 -1
View File
@@ -331,7 +331,8 @@
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
"resetTheme": "Zurücksetzen",
"saveTheme": "Theme speichern",
"mcp": "MCP"
"mcp": "MCP",
"documents": "Dokumente"
},
"auditLog": {
"title": "Audit-Log",
@@ -1466,5 +1467,98 @@
"failed": "fehlgeschlagen",
"importDone": "Import abgeschlossen",
"backgroundRunning": "Import läuft im Hintergrund …"
},
"documents": {
"settings": {
"subtitle": "Briefpapiere und Druckvorlagen mit Drag&Drop-Editor verwalten",
"tabs": "Dokumente-Bereiche"
},
"letterheads": "Briefpapiere",
"printTemplates": "Druckvorlagen",
"letterhead": {
"create": "Neues Briefpapier",
"edit": "Briefpapier bearbeiten",
"empty": "Noch kein Briefpapier angelegt.",
"default": "Standard",
"deleted": "Briefpapier gelöscht",
"deleteConfirm": "Briefpapier wirklich löschen?",
"nameRequired": "Name ist erforderlich",
"saved": "Briefpapier gespeichert",
"created": "Briefpapier angelegt",
"isDefault": "Als Standard-Briefpapier verwenden"
},
"template": {
"create": "Neue Vorlage",
"edit": "Vorlage bearbeiten",
"empty": "Noch keine Druckvorlage angelegt.",
"deleted": "Vorlage gelöscht",
"deleteConfirm": "Vorlage wirklich löschen?",
"nameRequired": "Name ist erforderlich",
"saved": "Vorlage gespeichert",
"created": "Vorlage angelegt",
"saveFailed": "Speichern fehlgeschlagen — Blöcke prüfen",
"noLetterhead": "Ohne Briefpapier",
"entityType": "Datenquelle (Modul)",
"entityTypeHint": "Bestimmt die verfügbaren Platzhalter (Modul-Beitrag)"
},
"page": {
"size": "Seitenformat",
"orientation": "Ausrichtung",
"portrait": "Hochformat",
"landscape": "Querformat",
"margins": "Seitenränder (mm)"
},
"watermark": "Wasserzeichen",
"watermarkText": "z.B. ENTWURF",
"header": "Kopfzeile",
"footer": "Fußzeile",
"assets": "Bilder & Logos",
"asset": {
"upload": "Bild hochladen",
"uploaded": "Bild hochgeladen (ID in Zwischenablage)",
"uploadFailed": "Upload fehlgeschlagen",
"empty": "Noch keine Bilder.",
"saveFirst": "Briefpapier erst speichern, dann Bilder hochladen."
},
"editor": {
"palette": "Blöcke",
"canvas": "Reihenfolge",
"configPreview": "Konfiguration & Vorschau",
"empty": "Blöcke aus der Palette hinzufügen",
"selectBlock": "Block in der Reihenfolge-Liste auswählen, um ihn zu konfigurieren.",
"content": "Inhalt",
"placeholdersHint": "{{platzhalter}} möglich",
"bold": "Fett",
"italic": "Kursiv",
"shape": "Form",
"line": "Linie",
"rect": "Rechteck",
"circle": "Kreis",
"width": "Breite",
"height": "Höhe (px)",
"color": "Farbe",
"background": "Füllung",
"thickness": "Dicke (px)",
"assetId": "Asset-ID (Briefpapier-Upload)",
"align": "Ausrichtung",
"left": "Links",
"center": "Zentriert",
"right": "Rechts",
"columns": "Spalten (Komma-getrennt)",
"rows": "Zeilen (JSON)",
"placeholderKey": "Datenfeld",
"label": "Anzeige-Label",
"preview": "Live-Vorschau",
"pagebreakHint": "Erzwingt einen Seitenwechsel im PDF.",
"noConfig": "Keine Konfiguration für diesen Block-Typ."
},
"dialog": {
"title": "Dokument erstellen",
"for": "Für",
"generate": "Erstellen",
"generated": "Dokument wurde erstellt",
"generateFailed": "Dokument konnte nicht erstellt werden",
"noTemplates": "Keine Druckvorlagen für diesen Datentyp. Vorlagen unter Einstellungen → Dokumente anlegen."
}
}
}
+95 -1
View File
@@ -331,7 +331,8 @@
"livePreviewDescription": "This is how the app looks with the current theme",
"resetTheme": "Reset",
"saveTheme": "Save theme",
"mcp": "MCP"
"mcp": "MCP",
"documents": "Documents"
},
"auditLog": {
"title": "Audit Log",
@@ -1466,5 +1467,98 @@
"failed": "failed",
"importDone": "Import finished",
"backgroundRunning": "Import running in background …"
},
"documents": {
"settings": {
"subtitle": "Manage letterheads and print templates with the drag&drop editor",
"tabs": "Document sections"
},
"letterheads": "Letterheads",
"printTemplates": "Print Templates",
"letterhead": {
"create": "New Letterhead",
"edit": "Edit Letterhead",
"empty": "No letterhead yet.",
"default": "Default",
"deleted": "Letterhead deleted",
"deleteConfirm": "Delete this letterhead?",
"nameRequired": "Name is required",
"saved": "Letterhead saved",
"created": "Letterhead created",
"isDefault": "Use as default letterhead"
},
"template": {
"create": "New Template",
"edit": "Edit Template",
"empty": "No print template yet.",
"deleted": "Template deleted",
"deleteConfirm": "Delete this template?",
"nameRequired": "Name is required",
"saved": "Template saved",
"created": "Template created",
"saveFailed": "Save failed — check blocks",
"noLetterhead": "Without letterhead",
"entityType": "Data source (module)",
"entityTypeHint": "Determines available placeholders (module contribution)"
},
"page": {
"size": "Page size",
"orientation": "Orientation",
"portrait": "Portrait",
"landscape": "Landscape",
"margins": "Margins (mm)"
},
"watermark": "Watermark",
"watermarkText": "e.g. DRAFT",
"header": "Header",
"footer": "Footer",
"assets": "Images & Logos",
"asset": {
"upload": "Upload image",
"uploaded": "Image uploaded (ID copied to clipboard)",
"uploadFailed": "Upload failed",
"empty": "No images yet.",
"saveFirst": "Save the letterhead first, then upload images."
},
"editor": {
"palette": "Blocks",
"canvas": "Order",
"configPreview": "Configuration & Preview",
"empty": "Add blocks from the palette",
"selectBlock": "Select a block in the order list to configure it.",
"content": "Content",
"placeholdersHint": "{{placeholder}} supported",
"bold": "Bold",
"italic": "Italic",
"shape": "Shape",
"line": "Line",
"rect": "Rectangle",
"circle": "Circle",
"width": "Width",
"height": "Height (px)",
"color": "Color",
"background": "Fill",
"thickness": "Thickness (px)",
"assetId": "Asset ID (letterhead upload)",
"align": "Alignment",
"left": "Left",
"center": "Center",
"right": "Right",
"columns": "Columns (comma-separated)",
"rows": "Rows (JSON)",
"placeholderKey": "Data field",
"label": "Display label",
"preview": "Live Preview",
"pagebreakHint": "Forces a page break in the PDF.",
"noConfig": "No configuration for this block type."
},
"dialog": {
"title": "Create Document",
"for": "For",
"generate": "Generate",
"generated": "Document created",
"generateFailed": "Document could not be created",
"noTemplates": "No print templates for this data type. Create templates under Settings → Documents."
}
}
}
+24 -2
View File
@@ -2,7 +2,7 @@
* Contact Detail Page — loads a single contact by ID from route params.
*/
import React from 'react';
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ContactDetail } from '@/components/contacts/ContactDetail';
@@ -10,8 +10,9 @@ import { ContactEditForm } from '@/components/contacts/ContactEditForm';
import { useWindowStore } from '@/store/windowStore';
import { useUnifiedContact, type UnifiedContact } from '@/api/hooks';
import { Button } from '@/components/ui/Button';
import { ChevronLeft } from 'lucide-react';
import { ChevronLeft, FileText } from 'lucide-react';
import { PrintButton } from '@/components/common/PrintButton';
import { DocumentGenerationDialog } from '@/components/documents/DocumentGenerationDialog';
import { usePermission } from '@/hooks/usePermission';
import { useAuthStore } from '@/store/authStore';
@@ -23,6 +24,7 @@ export function ContactDetailPage() {
const openWindow = useWindowStore((s) => s.openWindow);
const { hasPermission } = usePermission();
const authUser = useAuthStore((state) => state.user);
const [docDialogOpen, setDocDialogOpen] = useState(false);
const canAccess = (perm: string): boolean => {
return hasPermission(perm);
};
@@ -59,6 +61,17 @@ export function ContactDetailPage() {
</button>
<div className="flex-1" />
{canAccess('contacts:read') && <PrintButton targetId="contact-detail" />}
{canAccess('reports:generate') && (
<button
onClick={() => setDocDialogOpen(true)}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('documents.dialog.title', 'Dokument erstellen')}
title={t('documents.dialog.title', 'Dokument erstellen')}
data-testid="contact-detail-document-button"
>
<FileText className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
</button>
)}
</div>
<div className="flex-1 overflow-y-auto" id="contact-detail">
<ContactDetail
@@ -68,6 +81,15 @@ export function ContactDetailPage() {
onDeleted={handleDeleted}
/>
</div>
{contact && (
<DocumentGenerationDialog
open={docDialogOpen}
onClose={() => setDocDialogOpen(false)}
entityType={contact.type === 'company' ? 'company' : 'contact'}
entityId={contact.id}
entityLabel={contact.displayname}
/>
)}
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Document Settings — Verwaltungsoberfläche für den Dokumente-Generator
* (Phase L): Briefpapiere + Druckvorlagen mit Drag&Drop-Block-Editor.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FileText, Layers } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
import { useToast } from '@/components/ui/Toast';
import {
useLetterheads,
usePrintTemplates,
useCreateLetterhead,
useDeleteLetterhead,
useCreatePrintTemplate,
useDeletePrintTemplate,
} from '@/api/documents';
import { LetterheadEditor } from '@/components/documents/LetterheadEditor';
import { PrintTemplateEditor } from '@/components/documents/PrintTemplateEditor';
import type { Letterhead, PrintTemplate } from '@/api/documents';
export function DocumentSettingsPage() {
const { t } = useTranslation();
const toast = useToast();
const [tab, setTab] = useState<'letterheads' | 'templates'>('letterheads');
const [editingLetterhead, setEditingLetterhead] = useState<Letterhead | 'new' | null>(null);
const [editingTemplate, setEditingTemplate] = useState<PrintTemplate | 'new' | null>(null);
const { data: letterheads = [], isLoading: lhLoading } = useLetterheads();
const { data: templates = [], isLoading: tplLoading } = usePrintTemplates();
const createLh = useCreateLetterhead();
const deleteLh = useDeleteLetterhead();
const createTpl = useCreatePrintTemplate();
const deleteTpl = useDeletePrintTemplate();
return (
<div className="max-w-6xl mx-auto space-y-6" data-testid="document-settings-page">
<div className="flex items-center gap-3">
<FileText className="w-6 h-6 text-primary-600" aria-hidden="true" />
<div>
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.documents', 'Dokumente')}</h1>
<p className="text-sm text-secondary-500">
{t('documents.settings.subtitle', 'Briefpapiere und Druckvorlagen mit Drag&Drop-Editor verwalten')}
</p>
</div>
</div>
<div className="flex gap-2" role="tablist" aria-label={t('documents.settings.tabs', 'Dokumente-Bereiche')}>
<button
role="tab"
aria-selected={tab === 'letterheads'}
onClick={() => setTab('letterheads')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === 'letterheads' ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100'
}`}
data-testid="documents-tab-letterheads"
>
{t('documents.letterheads', 'Briefpapiere')} ({letterheads.length})
</button>
<button
role="tab"
aria-selected={tab === 'templates'}
onClick={() => setTab('templates')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === 'templates' ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100'
}`}
data-testid="documents-tab-templates"
>
{t('documents.printTemplates', 'Druckvorlagen')} ({templates.length})
</button>
</div>
{tab === 'letterheads' && (
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-secondary-900">{t('documents.letterheads', 'Briefpapiere')}</h2>
<Button
size="sm"
icon={<FileText className="w-4 h-4" aria-hidden="true" />}
onClick={() => setEditingLetterhead('new')}
data-testid="documents-create-letterhead"
>
{t('documents.letterhead.create', 'Neues Briefpapier')}
</Button>
</div>
{lhLoading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : letterheads.length === 0 ? (
<p className="text-sm text-secondary-500 py-8 text-center">
{t('documents.letterhead.empty', 'Noch kein Briefpapier angelegt.')}
</p>
) : (
<ul className="divide-y divide-secondary-100" data-testid="documents-letterhead-list">
{letterheads.map((lh) => (
<li key={lh.id} className="flex items-center justify-between py-3 gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-secondary-900 truncate">{lh.name}</span>
{lh.is_default && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary-100 text-primary-700">
{t('documents.letterhead.default', 'Standard')}
</span>
)}
</div>
<p className="text-xs text-secondary-500 truncate">{lh.description || '—'}</p>
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="secondary" onClick={() => setEditingLetterhead(lh)}>
{t('common.edit', 'Bearbeiten')}
</Button>
<Button
size="sm"
variant="danger"
onClick={async () => {
if (!window.confirm(t('documents.letterhead.deleteConfirm', 'Briefpapier wirklich löschen?'))) return;
try {
await deleteLh.mutateAsync(lh.id);
toast.success(t('documents.letterhead.deleted', 'Briefpapier gelöscht'));
} catch (e) {
toast.error(t('common.error', 'Fehler beim Löschen'));
}
}}
aria-label={t('common.delete', 'Löschen')}
>
{t('common.delete', 'Löschen')}
</Button>
</div>
</li>
))}
</ul>
)}
</Card>
)}
{tab === 'templates' && (
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-secondary-900">{t('documents.printTemplates', 'Druckvorlagen')}</h2>
<Button
size="sm"
icon={<Layers className="w-4 h-4" aria-hidden="true" />}
onClick={() => setEditingTemplate('new')}
data-testid="documents-create-template"
>
{t('documents.template.create', 'Neue Vorlage')}
</Button>
</div>
{tplLoading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : templates.length === 0 ? (
<p className="text-sm text-secondary-500 py-8 text-center">
{t('documents.template.empty', 'Noch keine Druckvorlage angelegt.')}
</p>
) : (
<ul className="divide-y divide-secondary-100" data-testid="documents-template-list">
{templates.map((tpl) => {
const lh = letterheads.find((l) => l.id === tpl.letterhead_id);
return (
<li key={tpl.id} className="flex items-center justify-between py-3 gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-secondary-900 truncate">{tpl.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary-100 text-secondary-600">
{tpl.entity_type}
</span>
<span className="text-xs text-secondary-400">{tpl.blocks.length} Blöcke</span>
</div>
<p className="text-xs text-secondary-500 truncate">
{lh ? `${t('documents.letterhead', 'Briefpapier')}: ${lh.name}` : t('documents.template.noLetterhead', 'Ohne Briefpapier')}
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="secondary" onClick={() => setEditingTemplate(tpl)}>
{t('common.edit', 'Bearbeiten')}
</Button>
<Button
size="sm"
variant="danger"
onClick={async () => {
if (!window.confirm(t('documents.template.deleteConfirm', 'Vorlage wirklich löschen?'))) return;
try {
await deleteTpl.mutateAsync(tpl.id);
toast.success(t('documents.template.deleted', 'Vorlage gelöscht'));
} catch (e) {
toast.error(t('common.error', 'Fehler beim Löschen'));
}
}}
aria-label={t('common.delete', 'Löschen')}
>
{t('common.delete', 'Löschen')}
</Button>
</div>
</li>
);
})}
</ul>
)}
</Card>
)}
{editingLetterhead && (
<LetterheadEditor
letterhead={editingLetterhead === 'new' ? null : editingLetterhead}
onClose={() => setEditingLetterhead(null)}
/>
)}
{editingTemplate && (
<PrintTemplateEditor
template={editingTemplate === 'new' ? null : editingTemplate}
letterheads={letterheads}
onClose={() => setEditingTemplate(null)}
/>
)}
</div>
);
}
+2 -1
View File
@@ -5,7 +5,7 @@ import { usePluginStore } from '@/store/pluginStore';
import { useUIStore } from '@/store/uiStore';
import { usePermission } from '@/hooks/usePermission';
import { useAuthStore } from '@/store/authStore';
import { Settings, Mail, Bell, Sparkles, Bot, Shield, Users, UsersRound, Package, ArrowLeft } from 'lucide-react';
import { Settings, Mail, Bell, Sparkles, Bot, Shield, Users, UsersRound, Package, ArrowLeft, FileText } from 'lucide-react';
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Settings,
@@ -16,6 +16,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Shield,
Users,
UsersRound,
FileText,
};
const FALLBACK_ICON = Package;
+2
View File
@@ -52,6 +52,7 @@ const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashb
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
const ReportsPage = React.lazy(() => import('@/pages/Reports').then(m => ({ default: m.ReportsPage })));
const DocumentSettingsPage = React.lazy(() => import('@/pages/DocumentSettings').then(m => ({ default: m.DocumentSettingsPage })));
const TasksPage = React.lazy(() => import('@/pages/Tasks').then(m => ({ default: m.TasksPage })));
const CommunicationPage = React.lazy(() => import('@/pages/Communication').then(m => ({ default: m.CommunicationPage })));
const WorkflowsPage = React.lazy(() => import('@/pages/Workflows').then(m => ({ default: m.WorkflowsPage })));
@@ -223,6 +224,7 @@ const router = createBrowserRouter([
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
{ path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
{ path: 'workspaces', element: withSuspense(<WorkspaceManagerPage />) },
{ path: 'documents', element: withSuspense(<DocumentSettingsPage />) },
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) },
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> },