From 0ec8502fd44b0823a1f866a5a58ceb72374724eb Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 23 Jul 2026 23:26:40 +0200 Subject: [PATCH] =?UTF-8?q?Phase=205=20Batch=205=20Task=205.19:=20Report?= =?UTF-8?q?=20Generator=20Frontend-Oberfl=C3=A4che?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created frontend/src/api/reports.ts: React Query hooks for templates, presets, generate - Created frontend/src/pages/Reports.tsx: 3-column layout (template list, editor, generate) - Preset quick-action buttons with format selection (PDF/Print/CSV/Excel) - Template editor with Jinja2 code textarea, name, output format selector - JSON data input for report parameters - Download history tracking - Route /reports registered in index.tsx (lazy-loaded) - i18n keys added to de.json and en.json (reports section + nav.reports) - 5 frontend tests: page render, template list, new template, select template, download history - TSC: 0 new errors (2 pre-existing Dms.tsx errors only) --- frontend/src/api/reports.ts | 162 +++++++ frontend/src/i18n/locales/de.json | 31 +- frontend/src/i18n/locales/en.json | 31 +- frontend/src/pages/Reports.tsx | 421 ++++++++++++++++++ frontend/src/pages/__tests__/Reports.test.tsx | 138 ++++++ frontend/src/routes/index.tsx | 2 + 6 files changed, 783 insertions(+), 2 deletions(-) create mode 100644 frontend/src/api/reports.ts create mode 100644 frontend/src/pages/Reports.tsx create mode 100644 frontend/src/pages/__tests__/Reports.test.tsx diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts new file mode 100644 index 0000000..d04a7f0 --- /dev/null +++ b/frontend/src/api/reports.ts @@ -0,0 +1,162 @@ +/** + * Report Generator plugin API client. + * + * All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target the + * Report Generator plugin routes under `/reports/...`. + */ + +import { apiClient, apiDelete, apiGet, apiPost, apiPut } from './client'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export type OutputFormat = 'csv' | 'excel' | 'json' | 'pdf' | 'print'; +export type TemplateType = 'jinja2' | 'sql'; + +export interface ReportTemplate { + id: string; + name: string; + description: string; + template_type: TemplateType; + content: string; + output_format: OutputFormat; + created_by: string; + deleted_at?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface TemplateCreateInput { + name: string; + description?: string; + template_type?: TemplateType; + content: string; + output_format?: OutputFormat; +} + +export interface TemplateUpdateInput { + name?: string; + description?: string; + template_type?: TemplateType; + content?: string; + output_format?: OutputFormat; +} + +export interface ReportGenerateInput { + template_id: string; + data: Record; + output_format?: OutputFormat; +} + +export interface PresetReportInfo { + key: string; + name: string; + description: string; + icon: string; + output_formats: OutputFormat[]; +} + +export interface PresetGenerateInput { + preset: string; + output_format: OutputFormat; + parameters: Record; +} + +// ─── React Query Hooks ───────────────────────────────────────────────────── + +const QUERY_KEYS = { + templates: ['reports', 'templates'] as const, + presets: ['reports', 'presets'] as const, +}; + +/** Fetch all report templates */ +export function useReportTemplates() { + return useQuery({ + queryKey: QUERY_KEYS.templates, + queryFn: () => apiGet('/reports/templates'), + }); +} + +/** Fetch a single report template by ID */ +export function useReportTemplate(templateId: string | null) { + return useQuery({ + queryKey: ['reports', 'templates', templateId], + queryFn: () => apiGet(`/reports/templates/${templateId}`), + enabled: !!templateId, + }); +} + +/** Create a new report template */ +export function useCreateReportTemplate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: TemplateCreateInput) => + apiPost('/reports/templates', input), + onSuccess: () => qc.invalidateQueries({ queryKey: QUERY_KEYS.templates }), + }); +} + +/** Update an existing report template */ +export function useUpdateReportTemplate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...input }: TemplateUpdateInput & { id: string }) => + apiPut(`/reports/templates/${id}`, input), + onSuccess: () => qc.invalidateQueries({ queryKey: QUERY_KEYS.templates }), + }); +} + +/** Delete a report template (soft-delete) */ +export function useDeleteReportTemplate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/reports/templates/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: QUERY_KEYS.templates }), + }); +} + +/** Fetch all preset report templates */ +export function useReportPresets() { + return useQuery({ + queryKey: QUERY_KEYS.presets, + queryFn: () => apiGet('/reports/presets'), + }); +} + +/** Generate a report from a template — returns a file download (blob) */ +export function useGenerateReport() { + return useMutation({ + mutationFn: async (input: ReportGenerateInput) => { + const response = await apiClient.post('/reports/generate', input, { + responseType: 'blob', + }); + return response; + }, + }); +} + +/** Generate a preset report — returns a file download (blob) */ +export function useGeneratePresetReport() { + return useMutation({ + mutationFn: async (input: PresetGenerateInput) => { + const response = await apiClient.post('/reports/presets/generate', input, { + responseType: 'blob', + }); + return response; + }, + }); +} + +// ─── Download Helper ─────────────────────────────────────────────────────── + +/** Trigger a browser download from a blob response */ +export function downloadBlob(blob: Blob, filename: string) { + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 1a96e96..8372334 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -17,7 +17,8 @@ "auditLog": "Audit-Log", "settings": "Einstellungen", "aiAssistant": "KI Assistent", - "mcpSettings": "MCP Einstellungen" + "mcpSettings": "MCP Einstellungen", + "reports": "Reports" }, "auth": { "login": "Anmelden", @@ -967,5 +968,33 @@ "tools": "Tools", "noTools": "Keine Tools verfügbar" } + }, + "reports": { + "title": "Reports", + "presetReports": "Vorgefertigte Berichte", + "templates": "Vorlagen", + "newTemplate": "Neue Vorlage", + "editTemplate": "Vorlage bearbeiten", + "selectTemplate": "Vorlage auswählen", + "noTemplates": "Noch keine Vorlagen", + "templateName": "Vorlagenname", + "templateContent": "Jinja2 Vorlageninhalt (HTML für PDF)", + "templateCreated": "Vorlage erfolgreich erstellt", + "templateUpdated": "Vorlage erfolgreich aktualisiert", + "templateDeleted": "Vorlage gelöscht", + "confirmDelete": "Diese Vorlage löschen?", + "errorNameRequired": "Name ist erforderlich", + "errorContentRequired": "Vorlageninhalt ist erforderlich", + "saveFailed": "Speichern fehlgeschlagen", + "deleteFailed": "Löschen fehlgeschlagen", + "generate": "Generieren", + "generateReport": "Bericht generieren", + "generated": "Bericht erfolgreich generiert", + "generateFailed": "Berichtsgenerierung fehlgeschlagen", + "jsonData": "Daten (JSON)", + "invalidJson": "Ungültige JSON-Daten", + "selectTemplateHint": "Wählen Sie eine Vorlage aus der Liste", + "downloadHistory": "Download-Verlauf", + "noDownloads": "Noch keine Downloads" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index bbe8348..12cf015 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -17,7 +17,8 @@ "auditLog": "Audit Log", "settings": "Settings", "aiAssistant": "AI Assistant", - "mcpSettings": "MCP Settings" + "mcpSettings": "MCP Settings", + "reports": "Reports" }, "auth": { "login": "Sign In", @@ -967,5 +968,33 @@ "tools": "Tools", "noTools": "No tools available" } + }, + "reports": { + "title": "Reports", + "presetReports": "Preset Reports", + "templates": "Templates", + "newTemplate": "New Template", + "editTemplate": "Edit Template", + "selectTemplate": "Select a template", + "noTemplates": "No templates yet", + "templateName": "Template name", + "templateContent": "Jinja2 template content (HTML for PDF)", + "templateCreated": "Template created successfully", + "templateUpdated": "Template updated successfully", + "templateDeleted": "Template deleted", + "confirmDelete": "Delete this template?", + "errorNameRequired": "Name is required", + "errorContentRequired": "Template content is required", + "saveFailed": "Failed to save template", + "deleteFailed": "Failed to delete template", + "generate": "Generate", + "generateReport": "Generate Report", + "generated": "Report generated successfully", + "generateFailed": "Failed to generate report", + "jsonData": "Data (JSON)", + "invalidJson": "Invalid JSON data", + "selectTemplateHint": "Select a template from the list", + "downloadHistory": "Download History", + "noDownloads": "No downloads yet" } } diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx new file mode 100644 index 0000000..2dd21fe --- /dev/null +++ b/frontend/src/pages/Reports.tsx @@ -0,0 +1,421 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + BarChart3, + Building2, + Calendar, + CalendarDays, + Download, + FileText, + Loader2, + Plus, + Printer, + Save, + ShieldCheck, + Trash2, + Users, +} from 'lucide-react'; +import clsx from 'clsx'; +import { useToast } from '@/components/ui/Toast'; +import { + useReportTemplates, + useCreateReportTemplate, + useUpdateReportTemplate, + useDeleteReportTemplate, + useReportPresets, + useGenerateReport, + useGeneratePresetReport, + downloadBlob, + type ReportTemplate, + type OutputFormat, + type PresetReportInfo, +} from '@/api/reports'; + +// ─── Icon mapping for presets ────────────────────────────────────────────── + +const PRESET_ICONS: Record = { + Users: , + Calendar: , + CalendarDays: , + Building2: , + ShieldCheck: , +}; + +// ─── Download history entry ──────────────────────────────────────────────── + +interface DownloadEntry { + id: string; + name: string; + format: string; + timestamp: string; +} + +// ─── Component ───────────────────────────────────────────────────────────── + +export function ReportsPage() { + const { t } = useTranslation(); + const toast = useToast(); + + // Data hooks + const { data: templates = [], isLoading: templatesLoading } = useReportTemplates(); + const { data: presets = [] } = useReportPresets(); + + // Mutations + const createTemplate = useCreateReportTemplate(); + const updateTemplate = useUpdateReportTemplate(); + const deleteTemplate = useDeleteReportTemplate(); + const generateReport = useGenerateReport(); + const generatePreset = useGeneratePresetReport(); + + // Local state + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [editorContent, setEditorContent] = useState(''); + const [editorName, setEditorName] = useState(''); + const [editorFormat, setEditorFormat] = useState('pdf'); + const [isNewTemplate, setIsNewTemplate] = useState(false); + const [jsonData, setJsonData] = useState('{}'); + const [downloadHistory, setDownloadHistory] = useState([]); + const [activePresetFormat, setActivePresetFormat] = useState('pdf'); + + // Select a template for editing + const selectTemplate = (tpl: ReportTemplate) => { + setSelectedTemplate(tpl); + setEditorContent(tpl.content); + setEditorName(tpl.name); + setEditorFormat(tpl.output_format); + setIsNewTemplate(false); + }; + + // Start creating a new template + const startNewTemplate = () => { + setSelectedTemplate(null); + setEditorContent('\n

{{ title }}

\n

{{ message }}

\n'); + setEditorName(''); + setEditorFormat('pdf'); + setIsNewTemplate(true); + }; + + // Save template (create or update) + const handleSaveTemplate = async () => { + if (!editorName.trim()) { + toast.error(t('reports.errorNameRequired', 'Name is required')); + return; + } + if (!editorContent.trim()) { + toast.error(t('reports.errorContentRequired', 'Template content is required')); + return; + } + + try { + if (isNewTemplate) { + await createTemplate.mutateAsync({ + name: editorName, + content: editorContent, + output_format: editorFormat, + template_type: 'jinja2', + }); + toast.success(t('reports.templateCreated', 'Template created successfully')); + } else if (selectedTemplate) { + await updateTemplate.mutateAsync({ + id: selectedTemplate.id, + name: editorName, + content: editorContent, + output_format: editorFormat, + }); + toast.success(t('reports.templateUpdated', 'Template updated successfully')); + } + } catch (err: any) { + toast.error(err?.message || t('reports.saveFailed', 'Failed to save template')); + } + }; + + // Delete template + const handleDeleteTemplate = async (tpl: ReportTemplate) => { + if (!confirm(t('reports.confirmDelete', 'Delete this template?'))) return; + try { + await deleteTemplate.mutateAsync(tpl.id); + if (selectedTemplate?.id === tpl.id) { + setSelectedTemplate(null); + setEditorContent(''); + } + toast.success(t('reports.templateDeleted', 'Template deleted')); + } catch (err: any) { + toast.error(err?.message || t('reports.deleteFailed', 'Failed to delete template')); + } + }; + + // Generate report from selected template + const handleGenerate = async () => { + if (!selectedTemplate) return; + let parsedData: Record; + try { + parsedData = JSON.parse(jsonData); + } catch { + toast.error(t('reports.invalidJson', 'Invalid JSON data')); + return; + } + + try { + const response = await generateReport.mutateAsync({ + template_id: selectedTemplate.id, + data: parsedData, + output_format: editorFormat, + }); + const filename = `report_${selectedTemplate.name}.${editorFormat === 'excel' ? 'xlsx' : editorFormat}`; + downloadBlob(response.data as Blob, filename); + setDownloadHistory((prev) => [ + { id: Date.now().toString(), name: selectedTemplate.name, format: editorFormat, timestamp: new Date().toLocaleString() }, + ...prev, + ]); + toast.success(t('reports.generated', 'Report generated successfully')); + } catch (err: any) { + toast.error(err?.message || t('reports.generateFailed', 'Failed to generate report')); + } + }; + + // Generate preset report + const handleGeneratePreset = async (preset: PresetReportInfo) => { + let parsedParams: Record = {}; + try { + parsedParams = JSON.parse(jsonData); + } catch { + // Use empty params if JSON is invalid + parsedParams = {}; + } + + try { + const response = await generatePreset.mutateAsync({ + preset: preset.key, + output_format: activePresetFormat, + parameters: parsedParams, + }); + const ext = activePresetFormat === 'excel' ? 'xlsx' : activePresetFormat === 'print' ? 'pdf' : activePresetFormat; + const filename = `${preset.key}_report.${ext}`; + downloadBlob(response.data as Blob, filename); + setDownloadHistory((prev) => [ + { id: Date.now().toString(), name: preset.name, format: activePresetFormat, timestamp: new Date().toLocaleString() }, + ...prev, + ]); + toast.success(t('reports.generated', 'Report generated successfully')); + } catch (err: any) { + toast.error(err?.message || t('reports.generateFailed', 'Failed to generate report')); + } + }; + + const isGenerating = generateReport.isPending || generatePreset.isPending; + const isSaving = createTemplate.isPending || updateTemplate.isPending; + + return ( +
+ {/* Header */} +
+
+ +

{t('reports.title', 'Reports')}

+
+
+ + {/* Preset Quick Actions */} +
+

{t('reports.presetReports', 'Preset Reports')}

+
+ {presets.map((preset) => ( +
+
+ {PRESET_ICONS[preset.icon] || } + {preset.name} +
+

{preset.description}

+
+ {preset.output_formats.map((fmt) => ( + + ))} +
+
+ ))} +
+
+ + {/* Main 3-column layout */} +
+ {/* Left: Template List */} +
+
+

{t('reports.templates', 'Templates')}

+ +
+
+ {templatesLoading ? ( +
+ +
+ ) : templates.length === 0 ? ( +

{t('reports.noTemplates', 'No templates yet')}

+ ) : ( +
    + {templates.map((tpl) => ( +
  • +
    selectTemplate(tpl)} + > +
    +

    {tpl.name}

    +

    {tpl.output_format}

    +
    + +
    +
  • + ))} +
+ )} +
+
+ + {/* Center: Template Editor */} +
+
+

+ {isNewTemplate ? t('reports.newTemplate', 'New Template') : selectedTemplate ? t('reports.editTemplate', 'Edit Template') : t('reports.selectTemplate', 'Select a template')} +

+
+ + +
+
+
+ setEditorName(e.target.value)} + placeholder={t('reports.templateName', 'Template name')} + className="w-full text-sm border border-secondary-300 rounded px-3 py-2" + data-testid="input-template-name" + /> +