diff --git a/apps/web/src/common/config/navigation.ts b/apps/web/src/common/config/navigation.ts index c74bd4c2..ede3d92a 100644 --- a/apps/web/src/common/config/navigation.ts +++ b/apps/web/src/common/config/navigation.ts @@ -165,7 +165,7 @@ export const NAV_CONFIG: NavGroup[] = [ label: 'Documents', path: '/documents', icon: FileText, - allowedRoles: ['Vendor', 'Admin'], + allowedRoles: ['Admin','Vendor'], }, ], }, diff --git a/apps/web/src/features/workforce/documents/DocumentVault.tsx b/apps/web/src/features/workforce/documents/DocumentVault.tsx new file mode 100644 index 00000000..fc147dbf --- /dev/null +++ b/apps/web/src/features/workforce/documents/DocumentVault.tsx @@ -0,0 +1,608 @@ +import { Button } from "@/common/components/ui/button"; +import { Card, CardContent } from "@/common/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/common/components/ui/dialog"; +import { Input } from "@/common/components/ui/input"; +import { Label } from "@/common/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/common/components/ui/select"; +import { Textarea } from "@/common/components/ui/textarea"; +import { Alert, AlertDescription } from "@/common/components/ui/alert"; +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import { useQueryClient } from "@tanstack/react-query"; +import { differenceInDays, format } from "date-fns"; +import { AnimatePresence, motion } from "framer-motion"; +import { + CheckCircle2, + Clock, + FileText, + Loader2, + Plus, + Search, + Upload, + Users, + Download, + ShieldCheck, + Eye, + Calendar, + XCircle, + AlertCircle, + X, + FileCheck, + Flag +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store/store"; +import { + useListStaff, + useListDocuments, + useCreateStaffDocument, + useUpdateStaffDocument +} from "@/dataconnect-generated/react"; +import { DocumentStatus, DocumentType } from "@/dataconnect-generated"; +import { dataConnect } from "@/features/auth/firebase"; +import { useToast } from "@/common/components/ui/use-toast"; +import { getStorage, ref, uploadBytesResumable, getDownloadURL } from "firebase/storage"; + +const DOCUMENT_TYPE_LABELS: Record = { + [DocumentType.W4_FORM]: "W-4 FORM", + [DocumentType.I9_FORM]: "I-9 FORM", + [DocumentType.STATE_TAX_FORM]: "STATE TAX FORM", + [DocumentType.DIRECT_DEPOSIT]: "DIRECT DEPOSIT", + [DocumentType.ID_COPY]: "ID COPY", + [DocumentType.SSN_CARD]: "SSN CARD", + [DocumentType.WORK_PERMIT]: "WORK PERMIT", +}; + +export default function DocumentVault() { + const [searchTerm, setSearchTerm] = useState(""); + const [showUploadModal, setShowUploadModal] = useState(false); + const [showVerificationModal, setShowVerificationModal] = useState(false); + const [selectedCell, setSelectedCell] = useState(null); + const [docTypeFilter, setDocTypeFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploading, setUploading] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [expiryDate, setExpiryDate] = useState(""); + const [verificationNotes, setVerificationNotes] = useState(""); + const [flagReason, setFlagReason] = useState(""); + + const { toast } = useToast(); + const queryClient = useQueryClient(); + const user = useSelector((state: RootState) => state.auth.user); + const storage = getStorage(); + + // Data Connect Queries + const { data: staffData, isLoading: staffLoading } = useListStaff(dataConnect); + const { data: docTypesData } = useListDocuments(dataConnect); + + const staff = useMemo(() => staffData?.staffs || [], [staffData]); + const staffDocuments = useMemo(() => { + return staff.flatMap(s => (s as any).staffDocuments_on_staff || []); + }, [staff]); + + const availableDocTypes = useMemo(() => { + const dbDocs = docTypesData?.documents || []; + return Object.entries(DOCUMENT_TYPE_LABELS).map(([type, label]) => { + // Find the template document from DB for this type + const dbDoc = dbDocs.find(d => d.documentType === type); + return { + id: dbDoc?.id || type, + name: label, + documentType: type, + dbDoc: dbDoc + }; + }); + }, [docTypesData]); + + // Mutations + const { mutateAsync: createDoc } = useCreateStaffDocument(dataConnect); + const { mutateAsync: updateDoc } = useUpdateStaffDocument(dataConnect); + + const userRole = user?.userRole?.toLowerCase() || "admin"; + const isVendor = userRole === "vendor"; + const isAdmin = userRole === "admin"; + + // Filter staff by vendor + const filteredStaff = useMemo(() => { + let result = [...staff]; + if (isVendor && user?.uid) { + result = result.filter(s => s.ownerId === user.uid || s.createdBy === user.email); + } + if (searchTerm) { + result = result.filter(s => + s.fullName?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + return result; + }, [staff, isVendor, user, searchTerm]); + + // Build document matrix + const documentMatrix = useMemo(() => { + const matrix: Record = {}; + + filteredStaff.forEach(emp => { + matrix[emp.id] = { + employee: emp, + documents: {}, + completionRate: 0, + }; + + availableDocTypes.forEach(type => { + matrix[emp.id].documents[type.documentType] = null; + }); + }); + + staffDocuments.forEach(doc => { + if (matrix[doc.staffId]) { + // Calculate status based on expiry + let status = doc.status; + if (doc.expiryDate && status !== DocumentStatus.VERIFIED) { + const days = differenceInDays(new Date(doc.expiryDate), new Date()); + if (days < 0) status = DocumentStatus.EXPIRING; + else if (days <= 30) status = DocumentStatus.EXPIRING; + } + + // Map by documentType if available, otherwise fallback to documentId + const docType = doc.document?.documentType || + availableDocTypes.find(t => t.id === doc.documentId)?.documentType; + + if (docType) { + matrix[doc.staffId].documents[docType] = { ...doc, status }; + } + } + }); + + // Calculate completion rates + Object.values(matrix).forEach(row => { + const uploaded = Object.values(row.documents).filter((d: any) => + d && (d.status === DocumentStatus.UPLOADED || d.status === DocumentStatus.VERIFIED) + ).length; + row.completionRate = availableDocTypes.length > 0 ? Math.round((uploaded / availableDocTypes.length) * 100) : 0; + }); + + return matrix; + }, [filteredStaff, staffDocuments, availableDocTypes]); + + // Stats + const stats = useMemo(() => { + let uploaded = 0, pending = 0, expiring = 0, expired = 0, missing = 0; + + Object.values(documentMatrix).forEach(row => { + Object.values(row.documents).forEach((doc: any) => { + if (!doc) { + missing++; + return; + } + if (doc.status === DocumentStatus.UPLOADED || doc.status === DocumentStatus.VERIFIED) { + uploaded++; + } else if (doc.status === DocumentStatus.PENDING) { + pending++; + } else if (doc.status === DocumentStatus.EXPIRING) { + // Check if actually expired + if (doc.expiryDate && differenceInDays(new Date(doc.expiryDate), new Date()) < 0) { + expired++; + } else { + expiring++; + } + } + }); + }); + + return { uploaded, pending, expiring, expired, missing }; + }, [documentMatrix]); + + // Filter matrix by status + const filteredMatrix = useMemo(() => { + let result = { ...documentMatrix }; + + if (statusFilter !== "all") { + result = Object.fromEntries( + Object.entries(result).filter(([_, row]) => { + const docs = Object.values(row.documents); + if (statusFilter === "uploaded") return docs.some((d: any) => d?.status === DocumentStatus.UPLOADED || d?.status === DocumentStatus.VERIFIED); + if (statusFilter === "pending") return docs.some((d: any) => d?.status === DocumentStatus.PENDING); + if (statusFilter === "expiring") return docs.some((d: any) => { + if (d?.status === DocumentStatus.EXPIRING && d?.expiryDate) { + const days = differenceInDays(new Date(d.expiryDate), new Date()); + return days >= 0; + } + return false; + }); + if (statusFilter === "expired") return docs.some((d: any) => { + if (d?.status === DocumentStatus.EXPIRING && d?.expiryDate) { + const days = differenceInDays(new Date(d.expiryDate), new Date()); + return days < 0; + } + return false; + }); + if (statusFilter === "missing") return docs.some((d: any) => !d || d.status === DocumentStatus.MISSING); + return true; + }) + ); + } + + return result; + }, [documentMatrix, statusFilter]); + + const handleCellClick = (staffId: string, documentId: string, existingDoc: any) => { + const emp = documentMatrix[staffId]?.employee; + const docTypeInfo = availableDocTypes.find(t => t.documentType === documentId || t.id === documentId); + setSelectedCell({ + id: existingDoc?.id, + staffId: staffId, + staffName: emp?.fullName, + documentId: docTypeInfo?.id || documentId, + documentTypeName: docTypeInfo?.name, + ...existingDoc, + }); + setExpiryDate(existingDoc?.expiryDate || ""); + setShowUploadModal(true); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']; + if (!allowedTypes.includes(file.type)) { + toast({ title: "Invalid file type", description: "Please upload PDF, JPG, or PNG files only", variant: "destructive" }); + return; + } + if (file.size > 5 * 1024 * 1024) { + toast({ title: "File too large", description: "Please upload files smaller than 5MB", variant: "destructive" }); + return; + } + setSelectedFile(file); + } + }; + + const handleUpload = async () => { + if (!selectedFile || !selectedCell) return; + setUploading(true); + setUploadProgress(0); + try { + const fileName = `documents/${selectedCell.staffId}/${selectedCell.documentId}/${Date.now()}_${selectedFile.name}`; + const storageRef = ref(storage, fileName); + const uploadTask = uploadBytesResumable(storageRef, selectedFile); + uploadTask.on('state_changed', (snapshot) => { + const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + setUploadProgress(progress); + }, (error) => { + toast({ title: "Upload failed", description: error.message, variant: "destructive" }); + setUploading(false); + }, async () => { + const downloadURL = await getDownloadURL(uploadTask.snapshot.ref); + if (selectedCell.id) { + await updateDoc({ + staffId: selectedCell.staffId, + documentId: selectedCell.documentId, + status: DocumentStatus.UPLOADED, + documentUrl: downloadURL, + expiryDate: expiryDate || null + }); + toast({ title: "Success", description: "Document updated successfully" }); + } else { + await createDoc({ + staffId: selectedCell.staffId, + staffName: selectedCell.staffName, + documentId: selectedCell.documentId, + status: DocumentStatus.UPLOADED, + documentUrl: downloadURL, + expiryDate: expiryDate || null + }); + toast({ title: "Success", description: "Document uploaded successfully" }); + } + queryClient.invalidateQueries(); + setShowUploadModal(false); + setSelectedCell(null); + setSelectedFile(null); + setExpiryDate(""); + setUploadProgress(0); + setUploading(false); + }); + } catch (error: any) { + toast({ title: "Error", description: error.message, variant: "destructive" }); + setUploading(false); + } + }; + + const handleVerify = async () => { + if (!selectedCell?.id) return; + setShowUploadModal(false); + setShowVerificationModal(true); + }; + + const confirmVerification = async () => { + if (!selectedCell?.id) return; + try { + await updateDoc({ + staffId: selectedCell.staffId, + documentId: selectedCell.documentId, + status: DocumentStatus.VERIFIED + }); + toast({ title: "Document Verified", description: `Document verified. ${verificationNotes ? 'Notes recorded.' : ''}` }); + queryClient.invalidateQueries(); + setShowVerificationModal(false); + setShowUploadModal(false); + setVerificationNotes(""); + setSelectedCell(null); + } catch (error: any) { + toast({ title: "Error", description: error.message, variant: "destructive" }); + } + }; + + const handleFlagIssue = async () => { + if (!selectedCell?.id || !flagReason) { + toast({ title: "Missing Information", description: "Please provide a reason for flagging this document", variant: "destructive" }); + return; + } + try { + await updateDoc({ + staffId: selectedCell.staffId, + documentId: selectedCell.documentId, + status: DocumentStatus.PENDING + }); + toast({ title: "Document Flagged", description: `Document flagged: ${flagReason}.` }); + queryClient.invalidateQueries(); + setShowUploadModal(false); + setFlagReason(""); + setSelectedCell(null); + } catch (error: any) { + toast({ title: "Error", description: error.message, variant: "destructive" }); + } + }; + + const renderCell = (doc: any, staffId: string, documentId: string) => { + const status = doc?.status || DocumentStatus.MISSING; + const daysUntilExpiry = doc?.expiryDate ? differenceInDays(new Date(doc.expiryDate), new Date()) : null; + + return ( + + ); + }; + + if (staffLoading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+ {/* Stats Bar */} +
+ setStatusFilter('all')}> + +
+
{staff.length}

Employees

+
+
+ setStatusFilter('uploaded')}> + +
+
{stats.uploaded}

Uploaded

+
+
+ setStatusFilter('pending')}> + +
+
{stats.pending}

Pending

+
+
+ setStatusFilter('expiring')}> + +
+
{stats.expiring}

Expiring

+
+
+ setStatusFilter('expired')}> + +
+
{stats.expired}

Expired

+
+
+ setStatusFilter('missing')}> + +
+
{stats.missing}

Missing

+
+
+
+ + {/* Search & Filter Bar */} +
+
+ + setSearchTerm(e.target.value)} /> +
+ + +
+ + {/* Document Matrix Table */} +
+
+ + + + + {availableDocTypes.map((type, idx) => ( + + ))} + + + + + {Object.entries(filteredMatrix).map(([empId, row]) => ( + + + {availableDocTypes.map(type => ( + + ))} + + ))} + + {Object.keys(filteredMatrix).length === 0 && ( + + )} + +
Employees +
+ {type.name} +
+
+
+
+
{row.employee.fullName?.charAt(0) || '?'}
+
+
+
+ {row.employee.fullName} +
+
+ {row.completionRate}% COMPLETE +
+
+
+
+ {renderCell(row.documents[type.documentType], empId, type.documentType)} +

No employees match your current filters

+
+
+
+ + + + + +
+ {selectedCell?.documentTypeName} +
+

Document management for {selectedCell?.staffName}

+
+
+ {selectedCell?.documentUrl ? ( +
+
+ +

Document Uploaded

{selectedCell.status}

+
+ + +
+
+
+
+ {selectedCell.status === DocumentStatus.VERIFIED ? <>Verified : selectedCell.status === DocumentStatus.UPLOADED ? <>Uploaded : {selectedCell.status}} +
+
+ {selectedCell.expiryDate ? format(new Date(selectedCell.expiryDate), 'MMM d, yyyy') : 'No Expiry'} +
+
+
+ ) : ( +
+
+ +
document.getElementById('file-upload')?.click()}> + {selectedFile ? ( +
+ +

{selectedFile.name}

{(selectedFile.size / 1024 / 1024).toFixed(2)} MB

+ +
+ ) : ( + <>

Click or drag to upload

PDF, JPG or PNG (max 5MB)

+ )} + +
+ {uploading && ( +
+
Uploading...{Math.round(uploadProgress)}%
+
+
+ )} +
+
+ + setExpiryDate(e.target.value)} min={new Date().toISOString().split('T')[0]} /> +
+
+ )} +
+ + {selectedCell?.id ? ( + <> + {isAdmin && selectedCell.status !== DocumentStatus.VERIFIED && ( + + )} + {isAdmin && ( + + )} + + ) : ( + + )} + + +
+ + + +
Verify Document Authenticity
+
+ By verifying this document, you confirm that you have reviewed it for authenticity and validity. +