feat: Implement compliance dashboard for administrators

This commit is contained in:
dhinesh-m24
2026-02-11 11:06:19 +05:30
parent 70d5dd1061
commit 89a882fb14
2 changed files with 319 additions and 0 deletions

View File

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

View File

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