433 lines
18 KiB
TypeScript
433 lines
18 KiB
TypeScript
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 { PrintButton } from '@/components/common/PrintButton';
|
|
import { printPdfBlob } from '@/utils/print';
|
|
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}`;
|
|
if (editorFormat === 'print') {
|
|
printPdfBlob(response.data as Blob);
|
|
} else {
|
|
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}`;
|
|
if (activePresetFormat === 'print') {
|
|
printPdfBlob(response.data as Blob);
|
|
} else {
|
|
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>
|
|
<PrintButton targetId="reports-content" filename="leocrm-report" />
|
|
</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" id="reports-content">
|
|
{/* 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>
|
|
);
|
|
}
|