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