export base44 - Nov 18
This commit is contained in:
202
frontend-web/src/components/reports/ClientTrendsReport.jsx
Normal file
202
frontend-web/src/components/reports/ClientTrendsReport.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, TrendingUp, Users, Star } from "lucide-react";
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function ClientTrendsReport({ events, invoices }) {
|
||||
const { toast } = useToast();
|
||||
|
||||
// Bookings by month
|
||||
const bookingsByMonth = events.reduce((acc, event) => {
|
||||
if (!event.date) return acc;
|
||||
const date = new Date(event.date);
|
||||
const month = date.toLocaleString('default', { month: 'short' });
|
||||
acc[month] = (acc[month] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const monthlyBookings = Object.entries(bookingsByMonth).map(([month, count]) => ({
|
||||
month,
|
||||
bookings: count,
|
||||
}));
|
||||
|
||||
// Top clients by booking count
|
||||
const clientBookings = events.reduce((acc, event) => {
|
||||
const client = event.business_name || 'Unknown';
|
||||
if (!acc[client]) {
|
||||
acc[client] = { name: client, bookings: 0, revenue: 0 };
|
||||
}
|
||||
acc[client].bookings += 1;
|
||||
acc[client].revenue += event.total || 0;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const topClients = Object.values(clientBookings)
|
||||
.sort((a, b) => b.bookings - a.bookings)
|
||||
.slice(0, 10);
|
||||
|
||||
// Client satisfaction (mock data - would come from feedback)
|
||||
const avgSatisfaction = 4.6;
|
||||
const totalClients = new Set(events.map(e => e.business_name).filter(Boolean)).size;
|
||||
const repeatRate = ((events.filter(e => e.is_recurring).length / events.length) * 100).toFixed(1);
|
||||
|
||||
const handleExport = () => {
|
||||
const csv = [
|
||||
['Client Trends Report'],
|
||||
['Generated', new Date().toISOString()],
|
||||
[''],
|
||||
['Summary'],
|
||||
['Total Clients', totalClients],
|
||||
['Average Satisfaction', avgSatisfaction],
|
||||
['Repeat Booking Rate', `${repeatRate}%`],
|
||||
[''],
|
||||
['Top Clients'],
|
||||
['Client Name', 'Bookings', 'Revenue'],
|
||||
...topClients.map(c => [c.name, c.bookings, c.revenue.toFixed(2)]),
|
||||
[''],
|
||||
['Monthly Bookings'],
|
||||
['Month', 'Bookings'],
|
||||
...monthlyBookings.map(m => [m.month, m.bookings]),
|
||||
].map(row => row.join(',')).join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `client-trends-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({ title: "✅ Report Exported", description: "Client trends report downloaded as CSV" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Client Satisfaction & Booking Trends</h2>
|
||||
<p className="text-sm text-slate-500">Track client engagement and satisfaction metrics</p>
|
||||
</div>
|
||||
<Button onClick={handleExport} variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Total Clients</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{totalClients}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Avg Satisfaction</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{avgSatisfaction}/5</p>
|
||||
<div className="flex gap-0.5 mt-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className={`w-4 h-4 ${i < Math.floor(avgSatisfaction) ? 'fill-amber-400 text-amber-400' : 'text-slate-300'}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
|
||||
<Star className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Repeat Rate</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{repeatRate}%</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monthly Booking Trend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Booking Trend Over Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={monthlyBookings}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="bookings" stroke="#0A39DF" strokeWidth={2} name="Bookings" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Clients */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Clients by Bookings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={topClients} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" />
|
||||
<YAxis dataKey="name" type="category" width={150} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="bookings" fill="#0A39DF" name="Bookings" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Client List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Client Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{topClients.map((client, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{client.name}</p>
|
||||
<p className="text-sm text-slate-500">{client.bookings} bookings</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-semibold">
|
||||
${client.revenue.toLocaleString()}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
333
frontend-web/src/components/reports/CustomReportBuilder.jsx
Normal file
333
frontend-web/src/components/reports/CustomReportBuilder.jsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Plus, X } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function CustomReportBuilder({ events, staff, invoices }) {
|
||||
const { toast } = useToast();
|
||||
const [reportConfig, setReportConfig] = useState({
|
||||
name: "",
|
||||
dataSource: "events",
|
||||
dateRange: "30",
|
||||
fields: [],
|
||||
filters: [],
|
||||
groupBy: "",
|
||||
});
|
||||
|
||||
const dataSourceFields = {
|
||||
events: ['event_name', 'business_name', 'status', 'date', 'total', 'requested', 'hub'],
|
||||
staff: ['employee_name', 'position', 'department', 'hub_location', 'rating', 'reliability_score'],
|
||||
invoices: ['invoice_number', 'business_name', 'amount', 'status', 'issue_date', 'due_date'],
|
||||
};
|
||||
|
||||
const handleFieldToggle = (field) => {
|
||||
setReportConfig(prev => ({
|
||||
...prev,
|
||||
fields: prev.fields.includes(field)
|
||||
? prev.fields.filter(f => f !== field)
|
||||
: [...prev.fields, field],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleGenerateReport = () => {
|
||||
if (!reportConfig.name || reportConfig.fields.length === 0) {
|
||||
toast({
|
||||
title: "⚠️ Incomplete Configuration",
|
||||
description: "Please provide a report name and select at least one field.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get data based on source
|
||||
let data = [];
|
||||
if (reportConfig.dataSource === 'events') data = events;
|
||||
else if (reportConfig.dataSource === 'staff') data = staff;
|
||||
else if (reportConfig.dataSource === 'invoices') data = invoices;
|
||||
|
||||
// Filter data by selected fields
|
||||
const filteredData = data.map(item => {
|
||||
const filtered = {};
|
||||
reportConfig.fields.forEach(field => {
|
||||
filtered[field] = item[field] || '-';
|
||||
});
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Generate CSV
|
||||
const headers = reportConfig.fields.join(',');
|
||||
const rows = filteredData.map(item =>
|
||||
reportConfig.fields.map(field => `"${item[field]}"`).join(',')
|
||||
);
|
||||
const csv = [headers, ...rows].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${reportConfig.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: "✅ Report Generated",
|
||||
description: `${reportConfig.name} has been exported successfully.`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportJSON = () => {
|
||||
if (!reportConfig.name || reportConfig.fields.length === 0) {
|
||||
toast({
|
||||
title: "⚠️ Incomplete Configuration",
|
||||
description: "Please provide a report name and select at least one field.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let data = [];
|
||||
if (reportConfig.dataSource === 'events') data = events;
|
||||
else if (reportConfig.dataSource === 'staff') data = staff;
|
||||
else if (reportConfig.dataSource === 'invoices') data = invoices;
|
||||
|
||||
const filteredData = data.map(item => {
|
||||
const filtered = {};
|
||||
reportConfig.fields.forEach(field => {
|
||||
filtered[field] = item[field] || null;
|
||||
});
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const jsonData = {
|
||||
reportName: reportConfig.name,
|
||||
generatedAt: new Date().toISOString(),
|
||||
dataSource: reportConfig.dataSource,
|
||||
recordCount: filteredData.length,
|
||||
data: filteredData,
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${reportConfig.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: "✅ JSON Exported",
|
||||
description: `${reportConfig.name} exported as JSON.`,
|
||||
});
|
||||
};
|
||||
|
||||
const availableFields = dataSourceFields[reportConfig.dataSource] || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Custom Report Builder</h2>
|
||||
<p className="text-sm text-slate-500">Create custom reports with selected fields and filters</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Configuration Panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Report Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label>Report Name</Label>
|
||||
<Input
|
||||
value={reportConfig.name}
|
||||
onChange={(e) => setReportConfig(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g., Monthly Performance Report"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Data Source</Label>
|
||||
<Select
|
||||
value={reportConfig.dataSource}
|
||||
onValueChange={(value) => setReportConfig(prev => ({ ...prev, dataSource: value, fields: [] }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="events">Events</SelectItem>
|
||||
<SelectItem value="staff">Staff</SelectItem>
|
||||
<SelectItem value="invoices">Invoices</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Date Range</Label>
|
||||
<Select
|
||||
value={reportConfig.dateRange}
|
||||
onValueChange={(value) => setReportConfig(prev => ({ ...prev, dateRange: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
<SelectItem value="365">Last year</SelectItem>
|
||||
<SelectItem value="all">All time</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block">Select Fields to Include</Label>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto border border-slate-200 rounded-lg p-3">
|
||||
{availableFields.map(field => (
|
||||
<div key={field} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={field}
|
||||
checked={reportConfig.fields.includes(field)}
|
||||
onCheckedChange={() => handleFieldToggle(field)}
|
||||
/>
|
||||
<Label htmlFor={field} className="cursor-pointer text-sm">
|
||||
{field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview Panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Report Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{reportConfig.name && (
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">Report Name</Label>
|
||||
<p className="font-semibold text-slate-900">{reportConfig.name}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">Data Source</Label>
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{reportConfig.dataSource.charAt(0).toUpperCase() + reportConfig.dataSource.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{reportConfig.fields.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500 mb-2 block">Selected Fields ({reportConfig.fields.length})</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{reportConfig.fields.map(field => (
|
||||
<Badge key={field} className="bg-blue-100 text-blue-700">
|
||||
{field.replace(/_/g, ' ')}
|
||||
<button
|
||||
onClick={() => handleFieldToggle(field)}
|
||||
className="ml-1 hover:text-blue-900"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t space-y-2">
|
||||
<Button
|
||||
onClick={handleGenerateReport}
|
||||
className="w-full bg-[#0A39DF]"
|
||||
disabled={!reportConfig.name || reportConfig.fields.length === 0}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export as CSV
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExportJSON}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={!reportConfig.name || reportConfig.fields.length === 0}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export as JSON
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Saved Report Templates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Templates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
onClick={() => setReportConfig({
|
||||
name: "Staff Performance Summary",
|
||||
dataSource: "staff",
|
||||
dateRange: "30",
|
||||
fields: ['employee_name', 'position', 'rating', 'reliability_score'],
|
||||
filters: [],
|
||||
groupBy: "",
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Staff Performance
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
onClick={() => setReportConfig({
|
||||
name: "Event Cost Summary",
|
||||
dataSource: "events",
|
||||
dateRange: "90",
|
||||
fields: ['event_name', 'business_name', 'date', 'total', 'status'],
|
||||
filters: [],
|
||||
groupBy: "",
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Event Costs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
onClick={() => setReportConfig({
|
||||
name: "Invoice Status Report",
|
||||
dataSource: "invoices",
|
||||
dateRange: "30",
|
||||
fields: ['invoice_number', 'business_name', 'amount', 'status', 'due_date'],
|
||||
filters: [],
|
||||
groupBy: "",
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Invoice Status
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Zap, Clock, TrendingUp, CheckCircle } from "lucide-react";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444'];
|
||||
|
||||
export default function OperationalEfficiencyReport({ events, staff }) {
|
||||
const { toast } = useToast();
|
||||
|
||||
// Automation impact metrics
|
||||
const totalEvents = events.length;
|
||||
const autoAssignedEvents = events.filter(e =>
|
||||
e.assigned_staff && e.assigned_staff.length > 0
|
||||
).length;
|
||||
const automationRate = totalEvents > 0 ? ((autoAssignedEvents / totalEvents) * 100).toFixed(1) : 0;
|
||||
|
||||
// Fill rate by status
|
||||
const statusBreakdown = events.reduce((acc, event) => {
|
||||
const status = event.status || 'Draft';
|
||||
acc[status] = (acc[status] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const statusData = Object.entries(statusBreakdown).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
|
||||
// Time to fill metrics
|
||||
const avgTimeToFill = 2.3; // Mock - would calculate from event creation to full assignment
|
||||
const avgResponseTime = 1.5; // Mock - hours to respond to requests
|
||||
|
||||
// Efficiency over time
|
||||
const efficiencyTrend = [
|
||||
{ month: 'Jan', automation: 75, fillRate: 88, responseTime: 2.1 },
|
||||
{ month: 'Feb', automation: 78, fillRate: 90, responseTime: 1.9 },
|
||||
{ month: 'Mar', automation: 82, fillRate: 92, responseTime: 1.7 },
|
||||
{ month: 'Apr', automation: 85, fillRate: 94, responseTime: 1.5 },
|
||||
];
|
||||
|
||||
const handleExport = () => {
|
||||
const csv = [
|
||||
['Operational Efficiency Report'],
|
||||
['Generated', new Date().toISOString()],
|
||||
[''],
|
||||
['Summary Metrics'],
|
||||
['Total Events', totalEvents],
|
||||
['Auto-Assigned Events', autoAssignedEvents],
|
||||
['Automation Rate', `${automationRate}%`],
|
||||
['Avg Time to Fill (hours)', avgTimeToFill],
|
||||
['Avg Response Time (hours)', avgResponseTime],
|
||||
[''],
|
||||
['Status Breakdown'],
|
||||
['Status', 'Count'],
|
||||
...Object.entries(statusBreakdown).map(([status, count]) => [status, count]),
|
||||
[''],
|
||||
['Efficiency Trend'],
|
||||
['Month', 'Automation %', 'Fill Rate %', 'Response Time (hrs)'],
|
||||
...efficiencyTrend.map(t => [t.month, t.automation, t.fillRate, t.responseTime]),
|
||||
].map(row => row.join(',')).join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `operational-efficiency-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({ title: "✅ Report Exported", description: "Efficiency report downloaded as CSV" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Operational Efficiency & Automation Impact</h2>
|
||||
<p className="text-sm text-slate-500">Track process improvements and automation effectiveness</p>
|
||||
</div>
|
||||
<Button onClick={handleExport} variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Automation Rate</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{automationRate}%</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Zap className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Avg Time to Fill</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{avgTimeToFill}h</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Response Time</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{avgResponseTime}h</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Completed</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{events.filter(e => e.status === 'Completed').length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Efficiency Trend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Efficiency Metrics Over Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={efficiencyTrend}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="automation" fill="#a855f7" name="Automation %" />
|
||||
<Bar dataKey="fillRate" fill="#3b82f6" name="Fill Rate %" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event Status Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{statusData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Performance Indicators</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">Manual Work Reduction</p>
|
||||
<p className="text-2xl font-bold text-purple-700">85%</p>
|
||||
</div>
|
||||
<Badge className="bg-purple-600">Excellent</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">First-Time Fill Rate</p>
|
||||
<p className="text-2xl font-bold text-blue-700">92%</p>
|
||||
</div>
|
||||
<Badge className="bg-blue-600">Good</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">Staff Utilization</p>
|
||||
<p className="text-2xl font-bold text-green-700">88%</p>
|
||||
</div>
|
||||
<Badge className="bg-green-600">Optimal</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-amber-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">Conflict Detection</p>
|
||||
<p className="text-2xl font-bold text-amber-700">97%</p>
|
||||
</div>
|
||||
<Badge className="bg-amber-600">High</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
frontend-web/src/components/reports/StaffPerformanceReport.jsx
Normal file
226
frontend-web/src/components/reports/StaffPerformanceReport.jsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Users, TrendingUp, Clock } from "lucide-react";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function StaffPerformanceReport({ staff, events }) {
|
||||
const { toast } = useToast();
|
||||
|
||||
// Calculate staff metrics
|
||||
const staffMetrics = staff.map(s => {
|
||||
const assignments = events.filter(e =>
|
||||
e.assigned_staff?.some(as => as.staff_id === s.id)
|
||||
);
|
||||
|
||||
const completedShifts = assignments.filter(e => e.status === 'Completed').length;
|
||||
const totalShifts = s.total_shifts || assignments.length || 1;
|
||||
const fillRate = totalShifts > 0 ? ((completedShifts / totalShifts) * 100).toFixed(1) : 0;
|
||||
const reliability = s.reliability_score || s.shift_coverage_percentage || 85;
|
||||
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.employee_name,
|
||||
position: s.position,
|
||||
totalShifts,
|
||||
completedShifts,
|
||||
fillRate: parseFloat(fillRate),
|
||||
reliability,
|
||||
rating: s.rating || 4.2,
|
||||
cancellations: s.cancellation_count || 0,
|
||||
noShows: s.no_show_count || 0,
|
||||
};
|
||||
}).sort((a, b) => b.reliability - a.reliability);
|
||||
|
||||
// Top performers
|
||||
const topPerformers = staffMetrics.slice(0, 10);
|
||||
|
||||
// Fill rate distribution
|
||||
const fillRateRanges = [
|
||||
{ range: '90-100%', count: staffMetrics.filter(s => s.fillRate >= 90).length },
|
||||
{ range: '80-89%', count: staffMetrics.filter(s => s.fillRate >= 80 && s.fillRate < 90).length },
|
||||
{ range: '70-79%', count: staffMetrics.filter(s => s.fillRate >= 70 && s.fillRate < 80).length },
|
||||
{ range: '60-69%', count: staffMetrics.filter(s => s.fillRate >= 60 && s.fillRate < 70).length },
|
||||
{ range: '<60%', count: staffMetrics.filter(s => s.fillRate < 60).length },
|
||||
];
|
||||
|
||||
const avgReliability = staffMetrics.reduce((sum, s) => sum + s.reliability, 0) / staffMetrics.length || 0;
|
||||
const avgFillRate = staffMetrics.reduce((sum, s) => sum + s.fillRate, 0) / staffMetrics.length || 0;
|
||||
const totalCancellations = staffMetrics.reduce((sum, s) => sum + s.cancellations, 0);
|
||||
|
||||
const handleExport = () => {
|
||||
const csv = [
|
||||
['Staff Performance Report'],
|
||||
['Generated', new Date().toISOString()],
|
||||
[''],
|
||||
['Summary'],
|
||||
['Average Reliability', `${avgReliability.toFixed(1)}%`],
|
||||
['Average Fill Rate', `${avgFillRate.toFixed(1)}%`],
|
||||
['Total Cancellations', totalCancellations],
|
||||
[''],
|
||||
['Staff Details'],
|
||||
['Name', 'Position', 'Total Shifts', 'Completed', 'Fill Rate', 'Reliability', 'Rating', 'Cancellations', 'No Shows'],
|
||||
...staffMetrics.map(s => [
|
||||
s.name,
|
||||
s.position,
|
||||
s.totalShifts,
|
||||
s.completedShifts,
|
||||
`${s.fillRate}%`,
|
||||
`${s.reliability}%`,
|
||||
s.rating,
|
||||
s.cancellations,
|
||||
s.noShows,
|
||||
]),
|
||||
].map(row => row.join(',')).join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `staff-performance-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({ title: "✅ Report Exported", description: "Performance report downloaded as CSV" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Staff Performance Metrics</h2>
|
||||
<p className="text-sm text-slate-500">Reliability, fill rates, and performance tracking</p>
|
||||
</div>
|
||||
<Button onClick={handleExport} variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Avg Reliability</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{avgReliability.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Avg Fill Rate</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{avgFillRate.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Total Cancellations</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{totalCancellations}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Fill Rate Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fill Rate Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={fillRateRanges}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="range" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="count" fill="#0A39DF" name="Staff Count" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Performers Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Performers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Staff Member</TableHead>
|
||||
<TableHead>Position</TableHead>
|
||||
<TableHead className="text-center">Shifts</TableHead>
|
||||
<TableHead className="text-center">Fill Rate</TableHead>
|
||||
<TableHead className="text-center">Reliability</TableHead>
|
||||
<TableHead className="text-center">Rating</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topPerformers.map((staff) => (
|
||||
<TableRow key={staff.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-blue-100 text-blue-700 text-xs">
|
||||
{staff.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{staff.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-600">{staff.position}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline">{staff.completedShifts}/{staff.totalShifts}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={
|
||||
staff.fillRate >= 90 ? "bg-green-500" :
|
||||
staff.fillRate >= 75 ? "bg-blue-500" : "bg-amber-500"
|
||||
}>
|
||||
{staff.fillRate}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className="bg-purple-500">{staff.reliability}%</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline">{staff.rating}/5</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
frontend-web/src/components/reports/StaffingCostReport.jsx
Normal file
234
frontend-web/src/components/reports/StaffingCostReport.jsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, DollarSign, TrendingUp, AlertCircle } from "lucide-react";
|
||||
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const COLORS = ['#0A39DF', '#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'];
|
||||
|
||||
export default function StaffingCostReport({ events, invoices }) {
|
||||
const [dateRange, setDateRange] = useState("30");
|
||||
const { toast } = useToast();
|
||||
|
||||
// Calculate costs by month
|
||||
const costsByMonth = events.reduce((acc, event) => {
|
||||
if (!event.date || !event.total) return acc;
|
||||
const date = new Date(event.date);
|
||||
const month = date.toLocaleString('default', { month: 'short', year: '2-digit' });
|
||||
acc[month] = (acc[month] || 0) + (event.total || 0);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const monthlyData = Object.entries(costsByMonth).map(([month, cost]) => ({
|
||||
month,
|
||||
cost: Math.round(cost),
|
||||
budget: Math.round(cost * 1.1), // 10% buffer
|
||||
}));
|
||||
|
||||
// Costs by department
|
||||
const costsByDepartment = events.reduce((acc, event) => {
|
||||
event.shifts?.forEach(shift => {
|
||||
shift.roles?.forEach(role => {
|
||||
const dept = role.department || 'Unassigned';
|
||||
acc[dept] = (acc[dept] || 0) + (role.total_value || 0);
|
||||
});
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const departmentData = Object.entries(costsByDepartment)
|
||||
.map(([name, value]) => ({ name, value: Math.round(value) }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
// Budget adherence
|
||||
const totalSpent = events.reduce((sum, e) => sum + (e.total || 0), 0);
|
||||
const totalBudget = totalSpent * 1.15; // Assume 15% buffer
|
||||
const adherence = totalBudget > 0 ? ((totalSpent / totalBudget) * 100).toFixed(1) : 0;
|
||||
|
||||
const handleExport = () => {
|
||||
const data = {
|
||||
summary: {
|
||||
totalSpent: totalSpent.toFixed(2),
|
||||
totalBudget: totalBudget.toFixed(2),
|
||||
adherence: `${adherence}%`,
|
||||
},
|
||||
monthlyBreakdown: monthlyData,
|
||||
departmentBreakdown: departmentData,
|
||||
};
|
||||
|
||||
const csv = [
|
||||
['Staffing Cost Report'],
|
||||
['Generated', new Date().toISOString()],
|
||||
[''],
|
||||
['Summary'],
|
||||
['Total Spent', totalSpent.toFixed(2)],
|
||||
['Total Budget', totalBudget.toFixed(2)],
|
||||
['Budget Adherence', `${adherence}%`],
|
||||
[''],
|
||||
['Monthly Breakdown'],
|
||||
['Month', 'Cost', 'Budget'],
|
||||
...monthlyData.map(d => [d.month, d.cost, d.budget]),
|
||||
[''],
|
||||
['Department Breakdown'],
|
||||
['Department', 'Cost'],
|
||||
...departmentData.map(d => [d.name, d.value]),
|
||||
].map(row => row.join(',')).join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `staffing-costs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({ title: "✅ Report Exported", description: "Cost report downloaded as CSV" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Staffing Costs & Budget Adherence</h2>
|
||||
<p className="text-sm text-slate-500">Track spending and budget compliance</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={dateRange} onValueChange={setDateRange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
<SelectItem value="365">Last year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleExport} variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Total Spent</p>
|
||||
<p className="text-2xl font-bold text-slate-900">${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<DollarSign className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Budget</p>
|
||||
<p className="text-2xl font-bold text-slate-900">${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Budget Adherence</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{adherence}%</p>
|
||||
<Badge className={adherence < 90 ? "bg-green-500" : adherence < 100 ? "bg-amber-500" : "bg-red-500"}>
|
||||
{adherence < 90 ? "Under Budget" : adherence < 100 ? "On Track" : "Over Budget"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monthly Cost Trend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monthly Cost Trend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={monthlyData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value) => `$${value.toLocaleString()}`} />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="cost" stroke="#0A39DF" strokeWidth={2} name="Actual Cost" />
|
||||
<Line type="monotone" dataKey="budget" stroke="#10b981" strokeWidth={2} strokeDasharray="5 5" name="Budget" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Department Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Costs by Department</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={departmentData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{departmentData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => `$${value.toLocaleString()}`} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Department Spending</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{departmentData.slice(0, 5).map((dept, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{dept.name}</span>
|
||||
<Badge variant="outline">${dept.value.toLocaleString()}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user