Generated by Spark: Create scheduled automatic report generation

This commit is contained in:
2026-02-05 16:56:56 +00:00
committed by GitHub
parent 4585c0c3b6
commit 5feb78e549
11 changed files with 1023 additions and 5 deletions

View File

@@ -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
---

View File

@@ -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([

View 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>
)
}

View File

@@ -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} />
}

View File

@@ -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"

View File

@@ -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" }
]
}
]

View File

@@ -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"
}
}

View File

@@ -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)

View 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
}
}

View File

@@ -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>()

View File

@@ -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'