2026-07-23 23:26:40 +02:00
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' ;
2026-07-26 02:35:44 +02:00
import { PrintButton } from '@/components/common/PrintButton' ;
2026-07-26 09:40:43 +02:00
import { printPdfBlob } from '@/utils/print' ;
2026-07-23 23:26:40 +02:00
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 } ` ;
2026-07-26 09:40:43 +02:00
if ( editorFormat === 'print' ) {
printPdfBlob ( response . data as Blob ) ;
} else {
downloadBlob ( response . data as Blob , filename ) ;
}
2026-07-23 23:26:40 +02:00
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 } ` ;
2026-07-26 09:40:43 +02:00
if ( activePresetFormat === 'print' ) {
printPdfBlob ( response . data as Blob ) ;
} else {
downloadBlob ( response . data as Blob , filename ) ;
}
2026-07-23 23:26:40 +02:00
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 >
2026-07-26 02:35:44 +02:00
< PrintButton targetId = "reports-content" filename = "leocrm-report" / >
2026-07-23 23:26:40 +02:00
< / 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 */ }
2026-07-26 02:35:44 +02:00
< div className = "flex flex-1 gap-4 min-h-0" id = "reports-content" >
2026-07-23 23:26:40 +02:00
{ /* 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 >
) ;
}