mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: Create scheduled automatic report generation
This commit is contained in:
@@ -88,6 +88,7 @@ This roadmap outlines the phased development plan for WorkForce Pro, a cloud-bas
|
||||
- ✅ Margin analysis and forecasting
|
||||
- ✅ Advanced reports view with multiple report types
|
||||
- ✅ Data export capabilities
|
||||
- ✅ Scheduled automatic report generation
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Code } from '@phosphor-icons/react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
export type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo' | 'profile' | 'roles-permissions' | 'workflow-templates' | 'parallel-approval-demo'
|
||||
export type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo' | 'profile' | 'roles-permissions' | 'workflow-templates' | 'parallel-approval-demo' | 'scheduled-reports'
|
||||
|
||||
function App() {
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -87,7 +87,7 @@ function App() {
|
||||
|
||||
const handleViewChange = (view: View) => {
|
||||
dispatch(setCurrentView(view))
|
||||
announce(`Navigated to ${view}`)
|
||||
announce(`Navigated to ${view}`, 'polite')
|
||||
}
|
||||
|
||||
useKeyboardShortcuts([
|
||||
|
||||
502
src/components/ScheduledReportsManager.tsx
Normal file
502
src/components/ScheduledReportsManager.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
useScheduledReports,
|
||||
type ReportType,
|
||||
type ReportFrequency,
|
||||
type ReportFormat,
|
||||
type ScheduledReport
|
||||
} from '@/hooks/use-scheduled-reports'
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Play,
|
||||
Pause,
|
||||
Trash,
|
||||
Plus,
|
||||
ChartBar,
|
||||
Download,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock as ClockIcon
|
||||
} from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Grid } from '@/components/ui/grid'
|
||||
import { Stack } from '@/components/ui/stack'
|
||||
import { MetricCard } from '@/components/ui/metric-card'
|
||||
import { useTranslation } from '@/hooks/use-translation'
|
||||
import { useAppSelector } from '@/store/hooks'
|
||||
import { formatDistance } from 'date-fns'
|
||||
|
||||
const reportTypeLabels: Record<ReportType, string> = {
|
||||
'margin-analysis': 'Margin Analysis',
|
||||
'revenue-summary': 'Revenue Summary',
|
||||
'payroll-summary': 'Payroll Summary',
|
||||
'timesheet-summary': 'Timesheet Summary',
|
||||
'expense-summary': 'Expense Summary',
|
||||
'cash-flow': 'Cash Flow',
|
||||
'compliance-status': 'Compliance Status',
|
||||
'worker-utilization': 'Worker Utilization'
|
||||
}
|
||||
|
||||
const frequencyLabels: Record<ReportFrequency, string> = {
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
quarterly: 'Quarterly'
|
||||
}
|
||||
|
||||
export function ScheduledReportsManager() {
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAppSelector(state => state.auth.user)
|
||||
|
||||
const {
|
||||
schedules,
|
||||
executions,
|
||||
createSchedule,
|
||||
deleteSchedule,
|
||||
pauseSchedule,
|
||||
resumeSchedule,
|
||||
runScheduleNow,
|
||||
getExecutionHistory
|
||||
} = useScheduledReports()
|
||||
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduledReport | null>(null)
|
||||
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'margin-analysis' as ReportType,
|
||||
frequency: 'monthly' as ReportFrequency,
|
||||
format: 'csv' as ReportFormat,
|
||||
recipients: ''
|
||||
})
|
||||
|
||||
const activeSchedules = schedules.filter(s => s.status === 'active').length
|
||||
const totalExecutions = executions.length
|
||||
const successRate = executions.length > 0
|
||||
? (executions.filter(e => e.status === 'success').length / executions.length * 100).toFixed(1)
|
||||
: 0
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Report name is required')
|
||||
return
|
||||
}
|
||||
|
||||
const recipientList = formData.recipients
|
||||
.split(',')
|
||||
.map(r => r.trim())
|
||||
.filter(r => r.length > 0)
|
||||
|
||||
createSchedule({
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
type: formData.type,
|
||||
frequency: formData.frequency,
|
||||
format: formData.format,
|
||||
status: 'active',
|
||||
recipients: recipientList,
|
||||
createdBy: currentUser?.email || 'unknown'
|
||||
})
|
||||
|
||||
toast.success(`Scheduled report "${formData.name}" created`)
|
||||
setIsCreateDialogOpen(false)
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'margin-analysis',
|
||||
frequency: 'monthly',
|
||||
format: 'csv',
|
||||
recipients: ''
|
||||
})
|
||||
}
|
||||
|
||||
const handleRunNow = async (id: string) => {
|
||||
const schedule = schedules.find(s => s.id === id)
|
||||
if (!schedule) return
|
||||
|
||||
toast.loading(`Running report "${schedule.name}"...`)
|
||||
const execution = await runScheduleNow(id)
|
||||
|
||||
if (execution?.status === 'success') {
|
||||
toast.success(`Report "${schedule.name}" completed successfully`)
|
||||
} else {
|
||||
toast.error(`Report "${schedule.name}" failed: ${execution?.error || 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePause = (id: string) => {
|
||||
const schedule = schedules.find(s => s.id === id)
|
||||
pauseSchedule(id)
|
||||
toast.info(`Report "${schedule?.name}" paused`)
|
||||
}
|
||||
|
||||
const handleResume = (id: string) => {
|
||||
const schedule = schedules.find(s => s.id === id)
|
||||
resumeSchedule(id)
|
||||
toast.success(`Report "${schedule?.name}" resumed`)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const schedule = schedules.find(s => s.id === id)
|
||||
if (confirm(`Delete scheduled report "${schedule?.name}"?`)) {
|
||||
deleteSchedule(id)
|
||||
toast.success(`Report "${schedule?.name}" deleted`)
|
||||
}
|
||||
}
|
||||
|
||||
const showHistory = (schedule: ScheduledReport) => {
|
||||
setSelectedSchedule(schedule)
|
||||
setIsHistoryDialogOpen(true)
|
||||
}
|
||||
|
||||
const history = selectedSchedule ? getExecutionHistory(selectedSchedule.id) : []
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<PageHeader
|
||||
title="Scheduled Reports"
|
||||
description="Automate report generation and delivery"
|
||||
actions={
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2" />
|
||||
Create Schedule
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={3} gap={4}>
|
||||
<MetricCard
|
||||
label="Active Schedules"
|
||||
value={activeSchedules.toString()}
|
||||
icon={<Calendar />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Total Executions"
|
||||
value={totalExecutions.toString()}
|
||||
icon={<ChartBar />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Success Rate"
|
||||
value={`${successRate}%`}
|
||||
icon={<CheckCircle />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid cols={2} gap={4}>
|
||||
{schedules.map((schedule) => (
|
||||
<Card key={schedule.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CardTitle className="text-lg">{schedule.name}</CardTitle>
|
||||
<Badge variant={schedule.status === 'active' ? 'default' : 'secondary'}>
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{schedule.description && (
|
||||
<CardDescription className="text-sm">{schedule.description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Stack spacing={4}>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">Type</div>
|
||||
<div className="font-medium">{reportTypeLabels[schedule.type]}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">Frequency</div>
|
||||
<div className="font-medium">{frequencyLabels[schedule.frequency]}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">Format</div>
|
||||
<div className="font-medium uppercase">{schedule.format}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">Run Count</div>
|
||||
<div className="font-medium">{schedule.runCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{schedule.lastRunDate && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock size={16} />
|
||||
<span>
|
||||
Last run: {formatDistance(new Date(schedule.lastRunDate), new Date(), { addSuffix: true })}
|
||||
</span>
|
||||
{schedule.lastRunStatus === 'success' ? (
|
||||
<CheckCircle size={16} className="text-success" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar size={16} />
|
||||
<span>
|
||||
Next run: {formatDistance(new Date(schedule.nextRunDate), new Date(), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRunNow(schedule.id)}
|
||||
>
|
||||
<Play size={16} className="mr-1.5" />
|
||||
Run Now
|
||||
</Button>
|
||||
|
||||
{schedule.status === 'active' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePause(schedule.id)}
|
||||
>
|
||||
<Pause size={16} className="mr-1.5" />
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleResume(schedule.id)}
|
||||
>
|
||||
<Play size={16} className="mr-1.5" />
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => showHistory(schedule)}
|
||||
>
|
||||
<ClockIcon size={16} className="mr-1.5" />
|
||||
History
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDelete(schedule.id)}
|
||||
>
|
||||
<Trash size={16} className="mr-1.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{schedules.length === 0 && (
|
||||
<Card className="col-span-full">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<ChartBar size={48} className="text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
No scheduled reports yet. Create your first automated report schedule.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2" />
|
||||
Create Schedule
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Scheduled Report</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set up an automated report to run on a regular schedule
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Label htmlFor="name">Report Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., Monthly Revenue Report"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Brief description of this report"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="type">Report Type</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value) => setFormData({ ...formData, type: value as ReportType })}
|
||||
>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(reportTypeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="frequency">Frequency</Label>
|
||||
<Select
|
||||
value={formData.frequency}
|
||||
onValueChange={(value) => setFormData({ ...formData, frequency: value as ReportFrequency })}
|
||||
>
|
||||
<SelectTrigger id="frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(frequencyLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="format">Format</Label>
|
||||
<Select
|
||||
value={formData.format}
|
||||
onValueChange={(value) => setFormData({ ...formData, format: value as ReportFormat })}
|
||||
>
|
||||
<SelectTrigger id="format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV</SelectItem>
|
||||
<SelectItem value="excel">Excel</SelectItem>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="recipients">Recipients (Optional)</Label>
|
||||
<Input
|
||||
id="recipients"
|
||||
value={formData.recipients}
|
||||
onChange={(e) => setFormData({ ...formData, recipients: e.target.value })}
|
||||
placeholder="email1@example.com, email2@example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Comma-separated email addresses
|
||||
</p>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>Create Schedule</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isHistoryDialogOpen} onOpenChange={setIsHistoryDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Execution History</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedSchedule?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<Stack spacing={2}>
|
||||
{history.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
No execution history yet
|
||||
</p>
|
||||
) : (
|
||||
history.map((execution) => (
|
||||
<div
|
||||
key={execution.id}
|
||||
className="flex items-center justify-between p-3 border border-border rounded-md"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{execution.status === 'success' ? (
|
||||
<CheckCircle size={20} className="text-success" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-destructive" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{new Date(execution.executedAt).toLocaleString()}
|
||||
</span>
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{execution.format}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{execution.recordCount} records
|
||||
{execution.error && ` • Error: ${execution.error}`}
|
||||
</div>
|
||||
</div>
|
||||
{execution.status === 'success' && (
|
||||
<Download size={20} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsHistoryDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -50,6 +50,7 @@ const ProfileView = lazy(() => import('@/components/views/profile-view').then(m
|
||||
const RolesPermissionsView = lazy(() => import('@/components/views/roles-permissions-view').then(m => ({ default: m.RolesPermissionsView })))
|
||||
const ApprovalWorkflowTemplateManager = lazy(() => import('@/components/ApprovalWorkflowTemplateManager').then(m => ({ default: m.ApprovalWorkflowTemplateManager })))
|
||||
const ParallelApprovalDemo = lazy(() => import('@/components/ParallelApprovalDemo').then(m => ({ default: m.ParallelApprovalDemo })))
|
||||
const ScheduledReportsManager = lazy(() => import('@/components/ScheduledReportsManager').then(m => ({ default: m.ScheduledReportsManager })))
|
||||
|
||||
interface ViewRouterProps {
|
||||
currentView: View
|
||||
@@ -270,6 +271,9 @@ export function ViewRouter({
|
||||
case 'parallel-approval-demo':
|
||||
return <ParallelApprovalDemo />
|
||||
|
||||
case 'scheduled-reports':
|
||||
return <ScheduledReportsManager />
|
||||
|
||||
default:
|
||||
return <DashboardView metrics={metrics} />
|
||||
}
|
||||
|
||||
@@ -107,6 +107,13 @@ export function ReportsNav({ currentView, setCurrentView, expandedGroups, toggle
|
||||
onClick={() => setCurrentView('custom-reports')}
|
||||
view="custom-reports"
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Clock size={20} />}
|
||||
label="Scheduled Reports"
|
||||
active={currentView === 'scheduled-reports'}
|
||||
onClick={() => setCurrentView('scheduled-reports')}
|
||||
view="scheduled-reports"
|
||||
/>
|
||||
<NavItem
|
||||
icon={<ClockCounterClockwise size={20} />}
|
||||
label="Missing Timesheets"
|
||||
|
||||
@@ -123,7 +123,8 @@
|
||||
{ "status": "completed", "text": "Missing timesheets report" },
|
||||
{ "status": "completed", "text": "Margin analysis and forecasting" },
|
||||
{ "status": "completed", "text": "Advanced reports view with multiple report types" },
|
||||
{ "status": "completed", "text": "Data export capabilities" }
|
||||
{ "status": "completed", "text": "Data export capabilities" },
|
||||
{ "status": "completed", "text": "Scheduled automatic report generation" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"componentShowcase": "Component Showcase",
|
||||
"businessLogicDemo": "Business Logic Demo",
|
||||
"dataAdmin": "Data Admin",
|
||||
"roadmap": "Roadmap"
|
||||
"roadmap": "Roadmap",
|
||||
"scheduledReports": "Scheduled Reports"
|
||||
},
|
||||
"common": {
|
||||
"search": "Search",
|
||||
@@ -1386,5 +1387,63 @@
|
||||
"componentLibrary": "Component Library",
|
||||
"businessLogicHooks": "Business Logic Hooks",
|
||||
"logOut": "Log Out"
|
||||
},
|
||||
"scheduledReports": {
|
||||
"title": "Scheduled Reports",
|
||||
"description": "Automate report generation and delivery",
|
||||
"createSchedule": "Create Schedule",
|
||||
"activeSchedules": "Active Schedules",
|
||||
"totalExecutions": "Total Executions",
|
||||
"successRate": "Success Rate",
|
||||
"reportName": "Report Name",
|
||||
"reportNamePlaceholder": "e.g., Monthly Revenue Report",
|
||||
"descriptionLabel": "Description (Optional)",
|
||||
"descriptionPlaceholder": "Brief description of this report",
|
||||
"reportType": "Report Type",
|
||||
"frequency": "Frequency",
|
||||
"format": "Format",
|
||||
"recipients": "Recipients (Optional)",
|
||||
"recipientsPlaceholder": "email1@example.com, email2@example.com",
|
||||
"recipientsHelper": "Comma-separated email addresses",
|
||||
"createDialogTitle": "Create Scheduled Report",
|
||||
"createDialogDescription": "Set up an automated report to run on a regular schedule",
|
||||
"runNow": "Run Now",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"history": "History",
|
||||
"delete": "Delete",
|
||||
"lastRun": "Last run: {{time}}",
|
||||
"nextRun": "Next run: {{time}}",
|
||||
"runCount": "Run Count",
|
||||
"type": "Type",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"failed": "Failed",
|
||||
"executionHistory": "Execution History",
|
||||
"noHistory": "No execution history yet",
|
||||
"noSchedules": "No scheduled reports yet. Create your first automated report schedule.",
|
||||
"reportCreated": "Scheduled report \"{{name}}\" created",
|
||||
"reportPaused": "Report \"{{name}}\" paused",
|
||||
"reportResumed": "Report \"{{name}}\" resumed",
|
||||
"reportDeleted": "Report \"{{name}}\" deleted",
|
||||
"runningReport": "Running report \"{{name}}\"...",
|
||||
"reportSuccess": "Report \"{{name}}\" completed successfully",
|
||||
"reportError": "Report \"{{name}}\" failed: {{error}}",
|
||||
"deleteConfirm": "Delete scheduled report \"{{name}}\"?",
|
||||
"reportNameRequired": "Report name is required",
|
||||
"records": "records",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"quarterly": "Quarterly",
|
||||
"marginAnalysis": "Margin Analysis",
|
||||
"revenueSummary": "Revenue Summary",
|
||||
"payrollSummary": "Payroll Summary",
|
||||
"timesheetSummary": "Timesheet Summary",
|
||||
"expenseSummary": "Expense Summary",
|
||||
"cashFlow": "Cash Flow",
|
||||
"complianceStatus": "Compliance Status",
|
||||
"workerUtilization": "Worker Utilization"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,3 +422,59 @@ useIsomorphicLayoutEffect(() => {
|
||||
3. **Use `useBreakpoint`** for responsive logic instead of multiple media queries
|
||||
4. **Use `useEvent`** for stable callbacks in performance-critical scenarios
|
||||
5. **Use `useFavorites`** with the Spark KV store for persistence across sessions
|
||||
|
||||
## Report Generation Hooks
|
||||
|
||||
### `useScheduledReports`
|
||||
Comprehensive hook for managing scheduled automatic report generation with configurable schedules, formats, and delivery.
|
||||
|
||||
```tsx
|
||||
const {
|
||||
schedules,
|
||||
executions,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
deleteSchedule,
|
||||
pauseSchedule,
|
||||
resumeSchedule,
|
||||
runScheduleNow,
|
||||
getSchedulesByType,
|
||||
getExecutionHistory
|
||||
} = useScheduledReports()
|
||||
|
||||
// Create a new scheduled report
|
||||
createSchedule({
|
||||
name: 'Monthly Revenue Report',
|
||||
type: 'revenue-summary',
|
||||
frequency: 'monthly',
|
||||
format: 'excel',
|
||||
status: 'active',
|
||||
recipients: ['manager@company.com'],
|
||||
createdBy: 'user@company.com'
|
||||
})
|
||||
|
||||
// Run a schedule immediately
|
||||
await runScheduleNow(scheduleId)
|
||||
|
||||
// Pause/resume schedules
|
||||
pauseSchedule(scheduleId)
|
||||
resumeSchedule(scheduleId)
|
||||
|
||||
// View execution history
|
||||
const history = getExecutionHistory(scheduleId)
|
||||
```
|
||||
|
||||
**Report Types:**
|
||||
- `margin-analysis` - Monthly margin and profitability analysis
|
||||
- `revenue-summary` - Complete revenue breakdown
|
||||
- `payroll-summary` - Payroll run summaries
|
||||
- `timesheet-summary` - Timesheet status and hours
|
||||
- `expense-summary` - Expense submissions and approvals
|
||||
- `cash-flow` - Income vs expenses overview
|
||||
- `compliance-status` - Compliance metrics and rates
|
||||
- `worker-utilization` - Worker hours and utilization
|
||||
|
||||
**Frequencies:** `daily`, `weekly`, `monthly`, `quarterly`
|
||||
|
||||
**Formats:** `csv`, `excel`, `json`, `pdf` (pdf planned)
|
||||
|
||||
|
||||
387
src/hooks/use-scheduled-reports.ts
Normal file
387
src/hooks/use-scheduled-reports.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useIndexedDBState } from './use-indexed-db-state'
|
||||
import { useInvoicesCrud } from './use-invoices-crud'
|
||||
import { usePayrollCrud } from './use-payroll-crud'
|
||||
import { useTimesheetsCrud } from './use-timesheets-crud'
|
||||
import { useExpensesCrud } from './use-expenses-crud'
|
||||
import { useDataExport } from './use-data-export'
|
||||
|
||||
export type ReportType =
|
||||
| 'margin-analysis'
|
||||
| 'revenue-summary'
|
||||
| 'payroll-summary'
|
||||
| 'timesheet-summary'
|
||||
| 'expense-summary'
|
||||
| 'cash-flow'
|
||||
| 'compliance-status'
|
||||
| 'worker-utilization'
|
||||
|
||||
export type ReportFrequency = 'daily' | 'weekly' | 'monthly' | 'quarterly'
|
||||
export type ReportFormat = 'csv' | 'excel' | 'pdf' | 'json'
|
||||
export type ReportStatus = 'active' | 'paused' | 'failed'
|
||||
|
||||
export interface ScheduledReport {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
type: ReportType
|
||||
frequency: ReportFrequency
|
||||
format: ReportFormat
|
||||
status: ReportStatus
|
||||
recipients: string[]
|
||||
filters?: Record<string, any>
|
||||
nextRunDate: string
|
||||
lastRunDate?: string
|
||||
lastRunStatus?: 'success' | 'failed'
|
||||
lastRunError?: string
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
runCount: number
|
||||
}
|
||||
|
||||
export interface ReportExecution {
|
||||
id: string
|
||||
scheduleId: string
|
||||
scheduleName: string
|
||||
reportType: ReportType
|
||||
executedAt: string
|
||||
status: 'success' | 'failed'
|
||||
format: ReportFormat
|
||||
recordCount: number
|
||||
fileSize?: number
|
||||
error?: string
|
||||
downloadUrl?: string
|
||||
}
|
||||
|
||||
export function useScheduledReports() {
|
||||
const [schedules, setSchedules] = useIndexedDBState<ScheduledReport[]>(
|
||||
'scheduled-reports',
|
||||
[]
|
||||
)
|
||||
const [executions, setExecutions] = useIndexedDBState<ReportExecution[]>(
|
||||
'report-executions',
|
||||
[]
|
||||
)
|
||||
|
||||
const { invoices } = useInvoicesCrud()
|
||||
const { payrollRuns } = usePayrollCrud()
|
||||
const { timesheets } = useTimesheetsCrud()
|
||||
const { expenses } = useExpensesCrud()
|
||||
const { exportToCSV, exportToExcel } = useDataExport()
|
||||
|
||||
const generateReportData = useCallback((type: ReportType, filters?: Record<string, any>) => {
|
||||
switch (type) {
|
||||
case 'margin-analysis': {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
return months.map((month, index) => {
|
||||
const monthRevenue = invoices
|
||||
.filter(inv => {
|
||||
const invDate = new Date(inv.issueDate)
|
||||
return invDate.getMonth() === index
|
||||
})
|
||||
.reduce((sum, inv) => sum + inv.amount, 0)
|
||||
|
||||
const monthCosts = payrollRuns
|
||||
.filter(pr => {
|
||||
const prDate = new Date(pr.periodEnding)
|
||||
return prDate.getMonth() === index
|
||||
})
|
||||
.reduce((sum, pr) => sum + pr.totalAmount, 0)
|
||||
|
||||
const margin = monthRevenue - monthCosts
|
||||
const marginPercentage = monthRevenue > 0 ? (margin / monthRevenue) * 100 : 0
|
||||
|
||||
return {
|
||||
month,
|
||||
revenue: monthRevenue,
|
||||
costs: monthCosts,
|
||||
margin,
|
||||
marginPercentage: marginPercentage.toFixed(2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
case 'revenue-summary': {
|
||||
return invoices.map(inv => ({
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
client: inv.clientName,
|
||||
amount: inv.amount,
|
||||
status: inv.status,
|
||||
issueDate: inv.issueDate,
|
||||
dueDate: inv.dueDate
|
||||
}))
|
||||
}
|
||||
|
||||
case 'payroll-summary': {
|
||||
return payrollRuns.map(pr => ({
|
||||
payrollId: pr.id,
|
||||
periodEnding: pr.periodEnding,
|
||||
workerCount: pr.workersCount,
|
||||
totalAmount: pr.totalAmount,
|
||||
status: pr.status,
|
||||
processedDate: pr.processedDate
|
||||
}))
|
||||
}
|
||||
|
||||
case 'timesheet-summary': {
|
||||
return timesheets.map(ts => ({
|
||||
workerName: ts.workerName,
|
||||
clientName: ts.clientName,
|
||||
weekEnding: ts.weekEnding,
|
||||
hours: ts.hours,
|
||||
status: ts.status,
|
||||
submittedDate: ts.submittedDate,
|
||||
amount: ts.amount
|
||||
}))
|
||||
}
|
||||
|
||||
case 'expense-summary': {
|
||||
return expenses.map(exp => ({
|
||||
workerName: exp.workerName,
|
||||
category: exp.category,
|
||||
amount: exp.amount,
|
||||
date: exp.date,
|
||||
status: exp.status,
|
||||
description: exp.description
|
||||
}))
|
||||
}
|
||||
|
||||
case 'cash-flow': {
|
||||
const income = invoices
|
||||
.filter(inv => inv.status === 'paid')
|
||||
.reduce((sum, inv) => sum + inv.amount, 0)
|
||||
|
||||
const payrollCosts = payrollRuns
|
||||
.filter(pr => pr.status === 'completed')
|
||||
.reduce((sum, pr) => sum + pr.totalAmount, 0)
|
||||
|
||||
const expenseCosts = expenses
|
||||
.filter(exp => exp.status === 'approved')
|
||||
.reduce((sum, exp) => sum + exp.amount, 0)
|
||||
|
||||
const netCashFlow = income - payrollCosts - expenseCosts
|
||||
|
||||
return [{
|
||||
totalIncome: income,
|
||||
payrollExpenses: payrollCosts,
|
||||
otherExpenses: expenseCosts,
|
||||
totalExpenses: payrollCosts + expenseCosts,
|
||||
netCashFlow,
|
||||
generatedAt: new Date().toISOString()
|
||||
}]
|
||||
}
|
||||
|
||||
case 'worker-utilization': {
|
||||
const workerStats = new Map<string, { name: string, totalHours: number, timesheetCount: number }>()
|
||||
|
||||
timesheets.forEach(ts => {
|
||||
const existing = workerStats.get(ts.workerId) || { name: ts.workerName, totalHours: 0, timesheetCount: 0 }
|
||||
existing.totalHours += ts.hours
|
||||
existing.timesheetCount += 1
|
||||
workerStats.set(ts.workerId, existing)
|
||||
})
|
||||
|
||||
return Array.from(workerStats.values()).map(stats => ({
|
||||
workerName: stats.name,
|
||||
totalHours: stats.totalHours,
|
||||
timesheetCount: stats.timesheetCount,
|
||||
averageHoursPerWeek: (stats.totalHours / stats.timesheetCount).toFixed(2),
|
||||
utilizationRate: ((stats.totalHours / (stats.timesheetCount * 40)) * 100).toFixed(2) + '%'
|
||||
}))
|
||||
}
|
||||
|
||||
case 'compliance-status': {
|
||||
const totalTimesheets = timesheets.length
|
||||
const approvedTimesheets = timesheets.filter(ts => ts.status === 'approved').length
|
||||
const totalInvoices = invoices.length
|
||||
const paidInvoices = invoices.filter(inv => inv.status === 'paid').length
|
||||
|
||||
return [{
|
||||
timesheetComplianceRate: totalTimesheets > 0 ? ((approvedTimesheets / totalTimesheets) * 100).toFixed(2) + '%' : '0%',
|
||||
invoicePaymentRate: totalInvoices > 0 ? ((paidInvoices / totalInvoices) * 100).toFixed(2) + '%' : '0%',
|
||||
totalTimesheets,
|
||||
approvedTimesheets,
|
||||
pendingTimesheets: totalTimesheets - approvedTimesheets,
|
||||
totalInvoices,
|
||||
paidInvoices,
|
||||
unpaidInvoices: totalInvoices - paidInvoices,
|
||||
generatedAt: new Date().toISOString()
|
||||
}]
|
||||
}
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}, [invoices, payrollRuns, timesheets, expenses])
|
||||
|
||||
const executeReport = useCallback(async (schedule: ScheduledReport): Promise<ReportExecution> => {
|
||||
const executionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
try {
|
||||
const data = generateReportData(schedule.type, schedule.filters)
|
||||
|
||||
let exportResult
|
||||
const filename = `${schedule.name.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}`
|
||||
|
||||
if (schedule.format === 'csv') {
|
||||
exportResult = exportToCSV(data, { filename })
|
||||
} else if (schedule.format === 'excel') {
|
||||
exportResult = exportToExcel(data, { filename })
|
||||
} else if (schedule.format === 'json') {
|
||||
const jsonString = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([jsonString], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${filename}.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
exportResult = true
|
||||
}
|
||||
|
||||
const execution: ReportExecution = {
|
||||
id: executionId,
|
||||
scheduleId: schedule.id,
|
||||
scheduleName: schedule.name,
|
||||
reportType: schedule.type,
|
||||
executedAt: new Date().toISOString(),
|
||||
status: 'success',
|
||||
format: schedule.format,
|
||||
recordCount: data.length
|
||||
}
|
||||
|
||||
setExecutions(prev => [execution, ...prev].slice(0, 100))
|
||||
|
||||
return execution
|
||||
} catch (error) {
|
||||
const execution: ReportExecution = {
|
||||
id: executionId,
|
||||
scheduleId: schedule.id,
|
||||
scheduleName: schedule.name,
|
||||
reportType: schedule.type,
|
||||
executedAt: new Date().toISOString(),
|
||||
status: 'failed',
|
||||
format: schedule.format,
|
||||
recordCount: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
|
||||
setExecutions(prev => [execution, ...prev].slice(0, 100))
|
||||
|
||||
return execution
|
||||
}
|
||||
}, [generateReportData, exportToCSV, exportToExcel, setExecutions])
|
||||
|
||||
const calculateNextRunDate = useCallback((frequency: ReportFrequency, fromDate: Date = new Date()): string => {
|
||||
const next = new Date(fromDate)
|
||||
|
||||
switch (frequency) {
|
||||
case 'daily':
|
||||
next.setDate(next.getDate() + 1)
|
||||
break
|
||||
case 'weekly':
|
||||
next.setDate(next.getDate() + 7)
|
||||
break
|
||||
case 'monthly':
|
||||
next.setMonth(next.getMonth() + 1)
|
||||
break
|
||||
case 'quarterly':
|
||||
next.setMonth(next.getMonth() + 3)
|
||||
break
|
||||
}
|
||||
|
||||
next.setHours(9, 0, 0, 0)
|
||||
return next.toISOString()
|
||||
}, [])
|
||||
|
||||
const createSchedule = useCallback((data: Omit<ScheduledReport, 'id' | 'createdAt' | 'runCount' | 'nextRunDate'>) => {
|
||||
const newSchedule: ScheduledReport = {
|
||||
...data,
|
||||
id: `schedule_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
runCount: 0,
|
||||
nextRunDate: calculateNextRunDate(data.frequency)
|
||||
}
|
||||
|
||||
setSchedules(prev => [...prev, newSchedule])
|
||||
return newSchedule
|
||||
}, [setSchedules, calculateNextRunDate])
|
||||
|
||||
const updateSchedule = useCallback((id: string, updates: Partial<ScheduledReport>) => {
|
||||
setSchedules(prev => prev.map(schedule =>
|
||||
schedule.id === id ? { ...schedule, ...updates } : schedule
|
||||
))
|
||||
}, [setSchedules])
|
||||
|
||||
const deleteSchedule = useCallback((id: string) => {
|
||||
setSchedules(prev => prev.filter(schedule => schedule.id !== id))
|
||||
}, [setSchedules])
|
||||
|
||||
const pauseSchedule = useCallback((id: string) => {
|
||||
updateSchedule(id, { status: 'paused' })
|
||||
}, [updateSchedule])
|
||||
|
||||
const resumeSchedule = useCallback((id: string) => {
|
||||
updateSchedule(id, { status: 'active' })
|
||||
}, [updateSchedule])
|
||||
|
||||
const runScheduleNow = useCallback(async (id: string) => {
|
||||
const schedule = schedules.find(s => s.id === id)
|
||||
if (!schedule) return
|
||||
|
||||
const execution = await executeReport(schedule)
|
||||
|
||||
updateSchedule(id, {
|
||||
lastRunDate: execution.executedAt,
|
||||
lastRunStatus: execution.status,
|
||||
lastRunError: execution.error,
|
||||
runCount: schedule.runCount + 1,
|
||||
nextRunDate: calculateNextRunDate(schedule.frequency)
|
||||
})
|
||||
|
||||
return execution
|
||||
}, [schedules, executeReport, updateSchedule, calculateNextRunDate])
|
||||
|
||||
useEffect(() => {
|
||||
const checkSchedules = () => {
|
||||
const now = new Date()
|
||||
|
||||
schedules.forEach(schedule => {
|
||||
if (schedule.status !== 'active') return
|
||||
|
||||
const nextRun = new Date(schedule.nextRunDate)
|
||||
if (now >= nextRun) {
|
||||
runScheduleNow(schedule.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const interval = setInterval(checkSchedules, 60000)
|
||||
checkSchedules()
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [schedules, runScheduleNow])
|
||||
|
||||
const getSchedulesByType = useCallback((type: ReportType) => {
|
||||
return schedules.filter(s => s.type === type)
|
||||
}, [schedules])
|
||||
|
||||
const getExecutionHistory = useCallback((scheduleId: string) => {
|
||||
return executions.filter(e => e.scheduleId === scheduleId)
|
||||
}, [executions])
|
||||
|
||||
return {
|
||||
schedules,
|
||||
executions,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
deleteSchedule,
|
||||
pauseSchedule,
|
||||
resumeSchedule,
|
||||
runScheduleNow,
|
||||
getSchedulesByType,
|
||||
getExecutionHistory
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const viewPreloadMap: Record<View, () => Promise<any>> = {
|
||||
'roles-permissions': () => import('@/components/views/roles-permissions-view'),
|
||||
'workflow-templates': () => import('@/components/ApprovalWorkflowTemplateManager'),
|
||||
'parallel-approval-demo': () => import('@/components/ParallelApprovalDemo'),
|
||||
'scheduled-reports': () => import('@/components/ScheduledReportsManager'),
|
||||
}
|
||||
|
||||
const preloadedViews = new Set<View>()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo' | 'profile' | 'roles-permissions' | 'workflow-templates' | 'parallel-approval-demo'
|
||||
type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo' | 'profile' | 'roles-permissions' | 'workflow-templates' | 'parallel-approval-demo' | 'scheduled-reports'
|
||||
|
||||
type Locale = 'en' | 'es' | 'fr'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user