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,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),
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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> },
|
||||
|
||||
Reference in New Issue
Block a user