feat: Implement compliance dashboard for administrators
This commit is contained in:
@@ -0,0 +1,316 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Badge } from "@/common/components/ui/badge";
|
||||||
|
import { Button } from "@/common/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/common/components/ui/card";
|
||||||
|
import { Input } from "@/common/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/common/components/ui/select";
|
||||||
|
import DashboardLayout from "@/features/layouts/DashboardLayout";
|
||||||
|
import { useListCertificates, useListStaff, useListTaxForms } from "@/dataconnect-generated/react";
|
||||||
|
import { dataConnect } from "@/features/auth/firebase";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
XCircle,
|
||||||
|
Clock
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
|
||||||
|
export default function ComplianceDashboard() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
|
||||||
|
const { data: staffData, isLoading: staffLoading } = useListStaff(dataConnect);
|
||||||
|
const { data: taxFormsData, isLoading: taxFormsLoading } = useListTaxForms(dataConnect);
|
||||||
|
const { data: certificatesData, isLoading: certificatesLoading } = useListCertificates(dataConnect);
|
||||||
|
|
||||||
|
const staff = staffData?.staffs || [];
|
||||||
|
const taxForms = taxFormsData?.taxForms || [];
|
||||||
|
const certificates = certificatesData?.certificates || [];
|
||||||
|
|
||||||
|
const isLoading = staffLoading || taxFormsLoading || certificatesLoading;
|
||||||
|
|
||||||
|
// Build compliance matrix
|
||||||
|
const complianceMatrix = useMemo(() => {
|
||||||
|
return staff.map(s => {
|
||||||
|
const staffTaxForms = taxForms.filter(tf => tf.staffId === s.id);
|
||||||
|
const i9 = staffTaxForms.find(tf => tf.formType === 'I9');
|
||||||
|
const w4 = staffTaxForms.find(tf => tf.formType === 'W4');
|
||||||
|
const staffCerts = certificates.filter(c => c.staffId === s.id);
|
||||||
|
|
||||||
|
const hasExpiredCerts = staffCerts.some(c => c.status === 'EXPIRED');
|
||||||
|
const hasExpiringCerts = staffCerts.some(c => c.status === 'EXPIRING_SOON' || c.status === 'EXPIRING');
|
||||||
|
|
||||||
|
let certsStatus = 'MISSING';
|
||||||
|
if (staffCerts.length > 0) {
|
||||||
|
if (hasExpiredCerts) certsStatus = 'EXPIRED';
|
||||||
|
else if (hasExpiringCerts) certsStatus = 'EXPIRING';
|
||||||
|
else certsStatus = 'CURRENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isI9Compliant = i9?.status === 'APPROVED';
|
||||||
|
const isW4Compliant = w4?.status === 'APPROVED';
|
||||||
|
const isCertsCompliant = staffCerts.length > 0 && !hasExpiredCerts;
|
||||||
|
|
||||||
|
const isCompliant = isI9Compliant && isW4Compliant && isCertsCompliant;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
fullName: s.fullName,
|
||||||
|
i9Status: i9?.status || 'MISSING',
|
||||||
|
w4Status: w4?.status || 'MISSING',
|
||||||
|
certsStatus,
|
||||||
|
isCompliant,
|
||||||
|
hasExpiringCerts,
|
||||||
|
hasExpiredCerts
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [staff, taxForms, certificates]);
|
||||||
|
|
||||||
|
// Filtered matrix
|
||||||
|
const filteredMatrix = useMemo(() => {
|
||||||
|
let result = complianceMatrix;
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
result = result.filter(row =>
|
||||||
|
row.fullName.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter === 'compliant') {
|
||||||
|
result = result.filter(row => row.isCompliant);
|
||||||
|
} else if (statusFilter === 'non-compliant') {
|
||||||
|
result = result.filter(row => !row.isCompliant);
|
||||||
|
} else if (statusFilter === 'expiring') {
|
||||||
|
result = result.filter(row => row.hasExpiringCerts);
|
||||||
|
} else if (statusFilter === 'missing') {
|
||||||
|
result = result.filter(row => row.i9Status === 'MISSING' || row.w4Status === 'MISSING' || row.certsStatus === 'MISSING');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [complianceMatrix, searchTerm, statusFilter]);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = complianceMatrix.length;
|
||||||
|
const compliant = complianceMatrix.filter(r => r.isCompliant).length;
|
||||||
|
const missingDocs = complianceMatrix.reduce((acc, r) => {
|
||||||
|
if (r.i9Status === 'MISSING') acc++;
|
||||||
|
if (r.w4Status === 'MISSING') acc++;
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
const expiringCerts = certificates.filter(c => c.status === 'EXPIRING_SOON' || c.status === 'EXPIRING').length;
|
||||||
|
const complianceRate = total > 0 ? Math.round((compliant / total) * 100) : 0;
|
||||||
|
|
||||||
|
return { total, compliant, missingDocs, expiringCerts, complianceRate };
|
||||||
|
}, [complianceMatrix, certificates]);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const headers = ["Staff Name", "I-9 Status", "W-4 Status", "Certifications Status", "Overall Compliance"];
|
||||||
|
const rows = filteredMatrix.map(r => [
|
||||||
|
r.fullName,
|
||||||
|
r.i9Status,
|
||||||
|
r.w4Status,
|
||||||
|
r.certsStatus,
|
||||||
|
r.isCompliant ? "Compliant" : "Non-Compliant"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = [headers, ...rows].map(e => e.join(",")).join("\n");
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute("download", `compliance_report_${format(new Date(), 'yyyy-MM-dd')}.csv`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'APPROVED':
|
||||||
|
case 'CURRENT':
|
||||||
|
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20">Current</Badge>;
|
||||||
|
case 'EXPIRING':
|
||||||
|
case 'EXPIRING_SOON':
|
||||||
|
return <Badge className="bg-amber-500/10 text-amber-600 border-amber-500/20">Expiring</Badge>;
|
||||||
|
case 'EXPIRED':
|
||||||
|
return <Badge className="bg-rose-500/10 text-rose-600 border-rose-500/20">Expired</Badge>;
|
||||||
|
case 'MISSING':
|
||||||
|
case 'NOT_STARTED':
|
||||||
|
return <Badge variant="outline" className="text-muted-foreground">Missing</Badge>;
|
||||||
|
case 'PENDING':
|
||||||
|
case 'SUBMITTED':
|
||||||
|
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-500/20">Pending</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge variant="secondary">{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout
|
||||||
|
title="Compliance Dashboard"
|
||||||
|
subtitle="Overview of staff compliance status and certifications"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats Bar */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="border-border/50 glass">
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-black text-foreground">{stats.complianceRate}%</p>
|
||||||
|
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Compliance Rate</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/50 glass">
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-rose-500/10 rounded-xl flex items-center justify-center border border-rose-500/20">
|
||||||
|
<FileText className="w-6 h-6 text-rose-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-black text-foreground">{stats.missingDocs}</p>
|
||||||
|
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Missing Docs</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/50 glass">
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-amber-500/10 rounded-xl flex items-center justify-center border border-amber-500/20">
|
||||||
|
<Clock className="w-6 h-6 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-black text-foreground">{stats.expiringCerts}</p>
|
||||||
|
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Expiring Certs</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/50 glass">
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-cyan-500/10 rounded-xl flex items-center justify-center border border-cyan-500/20">
|
||||||
|
<Users className="w-6 h-6 text-cyan-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-black text-foreground">{stats.total}</p>
|
||||||
|
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total Staff</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters & Actions */}
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div className="flex flex-1 items-center gap-4 w-full md:w-auto">
|
||||||
|
<Input
|
||||||
|
placeholder="Search staff..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
leadingIcon={<Search className="w-4 h-4" />}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="All Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="compliant">Compliant</SelectItem>
|
||||||
|
<SelectItem value="non-compliant">Non-Compliant</SelectItem>
|
||||||
|
<SelectItem value="expiring">Expiring Certs</SelectItem>
|
||||||
|
<SelectItem value="missing">Missing Docs</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleExport} variant="outline" className="gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Export Report
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/40 border-b border-border/50">
|
||||||
|
<th className="text-left py-4 px-6 font-bold text-xs uppercase tracking-wider text-muted-foreground">Staff Name</th>
|
||||||
|
<th className="text-center py-4 px-6 font-bold text-xs uppercase tracking-wider text-muted-foreground">I-9 Status</th>
|
||||||
|
<th className="text-center py-4 px-6 font-bold text-xs uppercase tracking-wider text-muted-foreground">W-4 Status</th>
|
||||||
|
<th className="text-center py-4 px-6 font-bold text-xs uppercase tracking-wider text-muted-foreground">Certifications</th>
|
||||||
|
<th className="text-center py-4 px-6 font-bold text-xs uppercase tracking-wider text-muted-foreground">Overall</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/40">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{filteredMatrix.map((row) => (
|
||||||
|
<motion.tr
|
||||||
|
key={row.id}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="hover:bg-primary/5 transition-colors group"
|
||||||
|
>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center text-primary font-bold text-xs">
|
||||||
|
{row.fullName.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-foreground">{row.fullName}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-center">
|
||||||
|
{getStatusBadge(row.i9Status)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-center">
|
||||||
|
{getStatusBadge(row.w4Status)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-center">
|
||||||
|
{getStatusBadge(row.certsStatus)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-center">
|
||||||
|
{row.isCompliant ? (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-emerald-600 font-bold text-xs">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
COMPLIANT
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-rose-600 font-bold text-xs">
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
NON-COMPLIANT
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{filteredMatrix.length === 0 && !isLoading && (
|
||||||
|
<div className="p-12 text-center text-muted-foreground">
|
||||||
|
No staff members found matching the criteria.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="p-12 text-center text-muted-foreground">
|
||||||
|
Loading compliance data...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ import TaskBoard from './features/operations/tasks/TaskBoard';
|
|||||||
import InvoiceList from './features/finance/invoices/InvoiceList';
|
import InvoiceList from './features/finance/invoices/InvoiceList';
|
||||||
import InvoiceDetail from './features/finance/invoices/InvoiceDetail';
|
import InvoiceDetail from './features/finance/invoices/InvoiceDetail';
|
||||||
import InvoiceEditor from './features/finance/invoices/InvoiceEditor';
|
import InvoiceEditor from './features/finance/invoices/InvoiceEditor';
|
||||||
|
import ComplianceDashboard from './features/workforce/compliance/ComplianceDashboard';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppRoutes Component
|
* AppRoutes Component
|
||||||
@@ -96,6 +98,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/staff" element={<StaffList />} />
|
<Route path="/staff" element={<StaffList />} />
|
||||||
<Route path="/staff/add" element={<AddStaff />} />
|
<Route path="/staff/add" element={<AddStaff />} />
|
||||||
<Route path="/staff/:id/edit" element={<EditStaff />} />
|
<Route path="/staff/:id/edit" element={<EditStaff />} />
|
||||||
|
<Route path="/compliance" element={<ComplianceDashboard />} />
|
||||||
{/* Business Routes */}
|
{/* Business Routes */}
|
||||||
<Route path="/clients" element={<ClientList />} />
|
<Route path="/clients" element={<ClientList />} />
|
||||||
<Route path="/clients/:id/edit" element={<EditClient />} />
|
<Route path="/clients/:id/edit" element={<EditClient />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user