Phase 5 Batch 5 Task 5.19: Report Generator Frontend-Oberfläche
- 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)
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── 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<ReportTemplate[]>({
|
||||
queryKey: QUERY_KEYS.templates,
|
||||
queryFn: () => apiGet<ReportTemplate[]>('/reports/templates'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single report template by ID */
|
||||
export function useReportTemplate(templateId: string | null) {
|
||||
return useQuery<ReportTemplate>({
|
||||
queryKey: ['reports', 'templates', templateId],
|
||||
queryFn: () => apiGet<ReportTemplate>(`/reports/templates/${templateId}`),
|
||||
enabled: !!templateId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new report template */
|
||||
export function useCreateReportTemplate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: TemplateCreateInput) =>
|
||||
apiPost<ReportTemplate>('/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<ReportTemplate>(`/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<PresetReportInfo[]>({
|
||||
queryKey: QUERY_KEYS.presets,
|
||||
queryFn: () => apiGet<PresetReportInfo[]>('/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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, React.ReactNode> = {
|
||||
Users: <Users className="w-5 h-5" />,
|
||||
Calendar: <Calendar className="w-5 h-5" />,
|
||||
CalendarDays: <CalendarDays className="w-5 h-5" />,
|
||||
Building2: <Building2 className="w-5 h-5" />,
|
||||
ShieldCheck: <ShieldCheck className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
// ─── 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<ReportTemplate | null>(null);
|
||||
const [editorContent, setEditorContent] = useState('');
|
||||
const [editorName, setEditorName] = useState('');
|
||||
const [editorFormat, setEditorFormat] = useState<OutputFormat>('pdf');
|
||||
const [isNewTemplate, setIsNewTemplate] = useState(false);
|
||||
const [jsonData, setJsonData] = useState('{}');
|
||||
const [downloadHistory, setDownloadHistory] = useState<DownloadEntry[]>([]);
|
||||
const [activePresetFormat, setActivePresetFormat] = useState<OutputFormat>('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('<html><body>\n <h1>{{ title }}</h1>\n <p>{{ message }}</p>\n</body></html>');
|
||||
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<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
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 (
|
||||
<div className="flex flex-col h-full gap-4 p-4" data-testid="reports-page">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart3 className="w-6 h-6 text-primary-600" />
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('reports.title', 'Reports')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset Quick Actions */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-secondary-200 p-4" data-testid="preset-quick-actions">
|
||||
<h2 className="text-sm font-semibold text-secondary-700 mb-3">{t('reports.presetReports', 'Preset Reports')}</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{presets.map((preset) => (
|
||||
<div
|
||||
key={preset.key}
|
||||
className="flex flex-col gap-2 p-3 border border-secondary-200 rounded-lg hover:border-primary-400 transition-colors min-w-[200px]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{PRESET_ICONS[preset.icon] || <FileText className="w-5 h-5" />}
|
||||
<span className="font-medium text-sm text-secondary-800">{preset.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500">{preset.description}</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{preset.output_formats.map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => {
|
||||
setActivePresetFormat(fmt);
|
||||
handleGeneratePreset(preset);
|
||||
}}
|
||||
disabled={isGenerating}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
fmt === 'pdf' && 'bg-red-100 text-red-700 hover:bg-red-200',
|
||||
fmt === 'print' && 'bg-blue-100 text-blue-700 hover:bg-blue-200',
|
||||
fmt === 'csv' && 'bg-green-100 text-green-700 hover:bg-green-200',
|
||||
fmt === 'excel' && 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200',
|
||||
)}
|
||||
data-testid={`preset-${preset.key}-${fmt}`}
|
||||
>
|
||||
{fmt === 'pdf' && <Download className="w-3 h-3 inline mr-1" />}
|
||||
{fmt === 'print' && <Printer className="w-3 h-3 inline mr-1" />}
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main 3-column layout */}
|
||||
<div className="flex flex-1 gap-4 min-h-0">
|
||||
{/* Left: Template List */}
|
||||
<div className="w-64 flex-shrink-0 bg-white rounded-lg shadow-sm border border-secondary-200 flex flex-col" data-testid="template-list-panel">
|
||||
<div className="flex items-center justify-between p-3 border-b border-secondary-200">
|
||||
<h2 className="text-sm font-semibold text-secondary-700">{t('reports.templates', 'Templates')}</h2>
|
||||
<button
|
||||
onClick={startNewTemplate}
|
||||
className="p-1 rounded hover:bg-secondary-100 text-primary-600"
|
||||
title={t('reports.newTemplate', 'New Template')}
|
||||
data-testid="btn-new-template"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{templatesLoading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-secondary-400" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400 text-center p-4">{t('reports.noTemplates', 'No templates yet')}</p>
|
||||
) : (
|
||||
<ul className="py-1">
|
||||
{templates.map((tpl) => (
|
||||
<li key={tpl.id}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-secondary-50',
|
||||
selectedTemplate?.id === tpl.id && 'bg-primary-50 border-l-2 border-primary-500',
|
||||
)}
|
||||
onClick={() => selectTemplate(tpl)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-800 truncate">{tpl.name}</p>
|
||||
<p className="text-xs text-secondary-400">{tpl.output_format}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteTemplate(tpl); }}
|
||||
className="p-1 rounded hover:bg-red-100 text-red-500"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Template Editor */}
|
||||
<div className="flex-1 bg-white rounded-lg shadow-sm border border-secondary-200 flex flex-col" data-testid="template-editor-panel">
|
||||
<div className="flex items-center justify-between p-3 border-b border-secondary-200">
|
||||
<h2 className="text-sm font-semibold text-secondary-700">
|
||||
{isNewTemplate ? t('reports.newTemplate', 'New Template') : selectedTemplate ? t('reports.editTemplate', 'Edit Template') : t('reports.selectTemplate', 'Select a template')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={editorFormat}
|
||||
onChange={(e) => setEditorFormat(e.target.value as OutputFormat)}
|
||||
className="text-sm border border-secondary-300 rounded px-2 py-1"
|
||||
data-testid="select-output-format"
|
||||
>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="print">Print</option>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="excel">Excel</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={isSaving || (!editorName.trim() && !isNewTemplate && !selectedTemplate)}
|
||||
className="flex items-center gap-1 px-3 py-1 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-50"
|
||||
data-testid="btn-save-template"
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 flex-1 flex flex-col gap-2 min-h-0">
|
||||
<input
|
||||
type="text"
|
||||
value={editorName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<textarea
|
||||
value={editorContent}
|
||||
onChange={(e) => setEditorContent(e.target.value)}
|
||||
placeholder={t('reports.templateContent', 'Jinja2 template content (HTML for PDF)')}
|
||||
className="flex-1 w-full text-sm font-mono border border-secondary-300 rounded px-3 py-2 resize-none"
|
||||
spellCheck={false}
|
||||
data-testid="textarea-template-content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview / Generate */}
|
||||
<div className="w-72 flex-shrink-0 bg-white rounded-lg shadow-sm border border-secondary-200 flex flex-col" data-testid="generate-panel">
|
||||
<div className="p-3 border-b border-secondary-200">
|
||||
<h2 className="text-sm font-semibold text-secondary-700">{t('reports.generate', 'Generate')}</h2>
|
||||
</div>
|
||||
<div className="p-3 flex-1 flex flex-col gap-3 overflow-y-auto">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary-600 mb-1 block">
|
||||
{t('reports.jsonData', 'Data (JSON)')}
|
||||
</label>
|
||||
<textarea
|
||||
value={jsonData}
|
||||
onChange={(e) => setJsonData(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
className="w-full text-xs font-mono border border-secondary-300 rounded px-2 py-1.5 h-40 resize-none"
|
||||
spellCheck={false}
|
||||
data-testid="textarea-json-data"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !selectedTemplate}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-50"
|
||||
data-testid="btn-generate-report"
|
||||
>
|
||||
{isGenerating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||
{t('reports.generateReport', 'Generate Report')}
|
||||
</button>
|
||||
{!selectedTemplate && (
|
||||
<p className="text-xs text-secondary-400 text-center">{t('reports.selectTemplateHint', 'Select a template from the list')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download History */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-secondary-200 p-4" data-testid="download-history">
|
||||
<h2 className="text-sm font-semibold text-secondary-700 mb-2">{t('reports.downloadHistory', 'Download History')}</h2>
|
||||
{downloadHistory.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400">{t('reports.noDownloads', 'No downloads yet')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary-100">
|
||||
{downloadHistory.map((entry) => (
|
||||
<li key={entry.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-secondary-400" />
|
||||
<span className="font-medium text-secondary-700">{entry.name}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-secondary-100 text-secondary-600 uppercase">{entry.format}</span>
|
||||
</div>
|
||||
<span className="text-xs text-secondary-400">{entry.timestamp}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ReportsPage } from '../Reports';
|
||||
|
||||
// Mock the API hooks
|
||||
vi.mock('@/api/reports', () => ({
|
||||
useReportTemplates: vi.fn(() => ({
|
||||
data: [
|
||||
{ id: 'tpl-1', name: 'My Report', description: 'Test', template_type: 'jinja2', content: '<html></html>', output_format: 'pdf', created_by: 'user-1' },
|
||||
{ id: 'tpl-2', name: 'CSV Report', description: 'Test CSV', template_type: 'jinja2', content: 'name,email', output_format: 'csv', created_by: 'user-1' },
|
||||
],
|
||||
isLoading: false,
|
||||
})),
|
||||
useReportPresets: vi.fn(() => ({
|
||||
data: [
|
||||
{ key: 'contact_list', name: 'Kontaktliste', description: 'Liste aller Kontakte', icon: 'Users', output_formats: ['pdf', 'print', 'csv', 'excel'] },
|
||||
{ key: 'company_list', name: 'Firmenliste', description: 'Liste aller Firmen', icon: 'Building2', output_formats: ['pdf', 'print', 'csv', 'excel'] },
|
||||
],
|
||||
})),
|
||||
useCreateReportTemplate: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
})),
|
||||
useUpdateReportTemplate: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
})),
|
||||
useDeleteReportTemplate: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
})),
|
||||
useGenerateReport: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ data: new Blob(['test'], { type: 'application/pdf' }) }),
|
||||
isPending: false,
|
||||
})),
|
||||
useGeneratePresetReport: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ data: new Blob(['test'], { type: 'application/pdf' }) }),
|
||||
isPending: false,
|
||||
})),
|
||||
downloadBlob: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback || key,
|
||||
i18n: { language: 'de' },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = ({ className }: { className?: string }) => <div className={className} data-testid="icon" />;
|
||||
return {
|
||||
BarChart3: Icon,
|
||||
Building2: Icon,
|
||||
Calendar: Icon,
|
||||
CalendarDays: Icon,
|
||||
Download: Icon,
|
||||
FileText: Icon,
|
||||
Loader2: Icon,
|
||||
Plus: Icon,
|
||||
Printer: Icon,
|
||||
Save: Icon,
|
||||
ShieldCheck: Icon,
|
||||
Trash2: Icon,
|
||||
Users: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
// Helper to render with providers
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ReportsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the reports page with title and preset quick actions', () => {
|
||||
renderWithProviders(<ReportsPage />);
|
||||
expect(screen.getByTestId('reports-page')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preset-quick-actions')).toBeInTheDocument();
|
||||
// Should have preset buttons for contact_list and company_list
|
||||
expect(screen.getByTestId('preset-contact_list-pdf')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preset-company_list-pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays templates in the template list', () => {
|
||||
renderWithProviders(<ReportsPage />);
|
||||
expect(screen.getByTestId('template-list-panel')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Report')).toBeInTheDocument();
|
||||
expect(screen.getByText('CSV Report')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking new template button shows editor with default content', () => {
|
||||
renderWithProviders(<ReportsPage />);
|
||||
const newBtn = screen.getByTestId('btn-new-template');
|
||||
fireEvent.click(newBtn);
|
||||
// Editor should be visible with template name input
|
||||
expect(screen.getByTestId('input-template-name')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('textarea-template-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('btn-save-template')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selecting a template loads it into the editor', () => {
|
||||
renderWithProviders(<ReportsPage />);
|
||||
// Click on the first template
|
||||
fireEvent.click(screen.getByText('My Report'));
|
||||
// Editor should show the template name
|
||||
const nameInput = screen.getByTestId('input-template-name') as HTMLInputElement;
|
||||
expect(nameInput.value).toBe('My Report');
|
||||
});
|
||||
|
||||
it('shows download history section', () => {
|
||||
renderWithProviders(<ReportsPage />);
|
||||
expect(screen.getByTestId('download-history')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m =>
|
||||
const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage })));
|
||||
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 })));
|
||||
|
||||
/** Centered spinner fallback for lazy-loaded routes */
|
||||
function PageLoader() {
|
||||
@@ -89,6 +90,7 @@ const router = createBrowserRouter([
|
||||
{ path: '/ai-assistant', element: withSuspense(<AIAssistantPage />) },
|
||||
{ path: '/automation', element: withSuspense(<AutomationDashboardPage />) },
|
||||
{ path: '/agents', element: withSuspense(<AgentDashboardPage />) },
|
||||
{ path: '/reports', element: withSuspense(<ReportsPage />) },
|
||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||
{
|
||||
path: '/settings',
|
||||
|
||||
Reference in New Issue
Block a user