diff --git a/apps/web/src/features/workforce/compliance/ComplianceDashboard.tsx b/apps/web/src/features/workforce/compliance/ComplianceDashboard.tsx new file mode 100644 index 00000000..a2e4af32 --- /dev/null +++ b/apps/web/src/features/workforce/compliance/ComplianceDashboard.tsx @@ -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 Current; + case 'EXPIRING': + case 'EXPIRING_SOON': + return Expiring; + case 'EXPIRED': + return Expired; + case 'MISSING': + case 'NOT_STARTED': + return Missing; + case 'PENDING': + case 'SUBMITTED': + return Pending; + default: + return {status}; + } + }; + + return ( + +
+ {/* Stats Bar */} +
+ + +
+ +
+
+

{stats.complianceRate}%

+

Compliance Rate

+
+
+
+ + + +
+ +
+
+

{stats.missingDocs}

+

Missing Docs

+
+
+
+ + + +
+ +
+
+

{stats.expiringCerts}

+

Expiring Certs

+
+
+
+ + + +
+ +
+
+

{stats.total}

+

Total Staff

+
+
+
+
+ + {/* Filters & Actions */} +
+
+ setSearchTerm(e.target.value)} + leadingIcon={} + className="max-w-xs" + /> + +
+ +
+ + {/* Table */} + + +
+ + + + + + + + + + + + + {filteredMatrix.map((row) => ( + + + + + + + + ))} + + +
Staff NameI-9 StatusW-4 StatusCertificationsOverall
+
+
+ {row.fullName.charAt(0)} +
+ {row.fullName} +
+
+ {getStatusBadge(row.i9Status)} + + {getStatusBadge(row.w4Status)} + + {getStatusBadge(row.certsStatus)} + + {row.isCompliant ? ( +
+ + COMPLIANT +
+ ) : ( +
+ + NON-COMPLIANT +
+ )} +
+ {filteredMatrix.length === 0 && !isLoading && ( +
+ No staff members found matching the criteria. +
+ )} + {isLoading && ( +
+ Loading compliance data... +
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index ac32b07a..84aa8276 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -27,6 +27,8 @@ import TaskBoard from './features/operations/tasks/TaskBoard'; import InvoiceList from './features/finance/invoices/InvoiceList'; import InvoiceDetail from './features/finance/invoices/InvoiceDetail'; import InvoiceEditor from './features/finance/invoices/InvoiceEditor'; +import ComplianceDashboard from './features/workforce/compliance/ComplianceDashboard'; + /** * AppRoutes Component @@ -96,6 +98,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> {/* Business Routes */} } /> } />