feat: Implement document vault for administrators
This commit is contained in:
@@ -165,7 +165,7 @@ export const NAV_CONFIG: NavGroup[] = [
|
|||||||
label: 'Documents',
|
label: 'Documents',
|
||||||
path: '/documents',
|
path: '/documents',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
allowedRoles: ['Vendor', 'Admin'],
|
allowedRoles: ['Admin','Vendor'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
608
apps/web/src/features/workforce/documents/DocumentVault.tsx
Normal file
608
apps/web/src/features/workforce/documents/DocumentVault.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
[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<any>(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<File | null>(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<string, any> = {};
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCellClick(staffId, documentId, doc)}
|
||||||
|
className={`w-full h-16 rounded-xl flex flex-col items-center justify-center transition-all hover:scale-[1.02] relative group border-none ${
|
||||||
|
status === DocumentStatus.MISSING ? "bg-gray-50/50" :
|
||||||
|
status === DocumentStatus.UPLOADED || status === DocumentStatus.VERIFIED ? "bg-cyan-50/50" :
|
||||||
|
"bg-orange-50/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status === DocumentStatus.MISSING ? (
|
||||||
|
<Plus className="w-5 h-5 text-gray-300" />
|
||||||
|
) : status === DocumentStatus.UPLOADED || status === DocumentStatus.VERIFIED ? (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<FileText className="w-5 h-5 text-cyan-500" />
|
||||||
|
{status === DocumentStatus.VERIFIED && (
|
||||||
|
<span className="text-[8px] text-cyan-600 font-bold uppercase tracking-tighter">Verified</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<Clock className="w-5 h-5 text-orange-400" />
|
||||||
|
<span className="text-[9px] text-orange-600 font-bold tracking-tight">
|
||||||
|
{status === DocumentStatus.PENDING ? 'Pending' : (daysUntilExpiry !== null && daysUntilExpiry < 0 ? 'Expired' : `${daysUntilExpiry}d`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (staffLoading) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout title="Employee Documents" subtitle="Track and manage all required documents">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout title="Employee Documents" subtitle="Track and manage all required documents">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats Bar */}
|
||||||
|
<div className="grid grid-cols-6 gap-4">
|
||||||
|
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'all' ? 'ring-2 ring-blue-500' : ''}`} onClick={() => setStatusFilter('all')}>
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center"><Users className="w-6 h-6 text-blue-600" /></div>
|
||||||
|
<div><span className="text-2xl font-bold text-gray-900">{staff.length}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Employees</p></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'uploaded' ? 'ring-2 ring-blue-500' : ''}`} onClick={() => setStatusFilter('uploaded')}>
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-cyan-50 rounded-2xl flex items-center justify-center"><CheckCircle2 className="w-6 h-6 text-cyan-500" /></div>
|
||||||
|
<div><span className="text-2xl font-bold text-gray-900">{stats.uploaded}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Uploaded</p></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'pending' ? 'ring-2 ring-orange-500' : ''}`} onClick={() => setStatusFilter('pending')}>
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-orange-50 rounded-2xl flex items-center justify-center"><Clock className="w-6 h-6 text-orange-500" /></div>
|
||||||
|
<div><span className="text-2xl font-bold text-gray-900">{stats.pending}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Pending</p></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'expiring' ? 'ring-2 ring-amber-500' : ''}`} onClick={() => setStatusFilter('expiring')}>
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-amber-50 rounded-2xl flex items-center justify-center"><AlertCircle className="w-6 h-6 text-amber-500" /></div>
|
||||||
|
<div><span className="text-2xl font-bold text-gray-900">{stats.expiring}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Expiring</p></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'expired' ? 'ring-2 ring-red-500' : ''}`} onClick={() => setStatusFilter('expired')}>
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-red-50 rounded-2xl flex items-center justify-center"><XCircle className="w-6 h-6 text-red-500" /></div>
|
||||||
|
<div><span className="text-2xl font-bold text-gray-900">{stats.expired}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Expired</p></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'missing' ? 'ring-2 ring-gray-500' : ''}`} onClick={() => setStatusFilter('missing')}>
|
||||||
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-gray-50 rounded-2xl flex items-center justify-center"><Plus className="w-6 h-6 text-gray-400" /></div>
|
||||||
|
<div><span className="text-2xl font-bold text-gray-900">{stats.missing}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Missing</p></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filter Bar */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<Input placeholder="Search employees..." className="pl-12 h-12 bg-white border-gray-100 rounded-xl shadow-sm" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Select value={docTypeFilter} onValueChange={setDocTypeFilter}>
|
||||||
|
<SelectTrigger className="w-[80px] h-12 bg-white border-gray-100 rounded-xl"><SelectValue placeholder="All" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
{availableDocTypes.map(type => <SelectItem key={type.documentType} value={type.documentType}>{type.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[80px] h-12 bg-white border-gray-100 rounded-xl"><SelectValue placeholder="All" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
<SelectItem value="uploaded">Uploaded</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="expiring">Expiring</SelectItem>
|
||||||
|
<SelectItem value="expired">Expired</SelectItem>
|
||||||
|
<SelectItem value="missing">Missing</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Matrix Table */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100">
|
||||||
|
<th className="text-left py-6 px-8 font-bold text-gray-400 text-[10px] uppercase tracking-wider min-w-[280px]">Employees</th>
|
||||||
|
{availableDocTypes.map((type, idx) => (
|
||||||
|
<th key={type.documentType} className="p-4 min-w-[160px]">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span className="font-bold text-gray-600 text-[10px] uppercase tracking-wider">{type.name}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{Object.entries(filteredMatrix).map(([empId, row]) => (
|
||||||
|
<motion.tr key={empId} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="group">
|
||||||
|
<td className="py-6 px-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-blue-50 flex items-center justify-center font-bold text-blue-600 text-lg">{row.employee.fullName?.charAt(0) || '?'}</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-blue-600 rounded-full border-2 border-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-bold text-gray-900 text-sm">{row.employee.fullName}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 w-20 bg-gray-50 rounded-full overflow-hidden"><div className="h-full bg-blue-600" style={{ width: `${row.completionRate}%` }} /></div>
|
||||||
|
<span className="text-[9px] font-bold text-gray-400 uppercase tracking-wider">{row.completionRate}% COMPLETE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{availableDocTypes.map(type => (
|
||||||
|
<td key={type.documentType} className="p-3">
|
||||||
|
{renderCell(row.documents[type.documentType], empId, type.documentType)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
{Object.keys(filteredMatrix).length === 0 && (
|
||||||
|
<tr><td colSpan={availableDocTypes.length + 1} className="py-12 text-center"><p className="text-gray-500">No employees match your current filters</p></td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={showUploadModal} onOpenChange={setShowUploadModal}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center"><FileText className="w-4 h-4 text-blue-600" /></div>
|
||||||
|
{selectedCell?.documentTypeName}
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-sm text-gray-500">Document management for <span className="font-semibold text-gray-900">{selectedCell?.staffName}</span></p>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{selectedCell?.documentUrl ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="aspect-[16/9] rounded-xl border border-gray-200 bg-gray-50 flex flex-col items-center justify-center gap-3 relative overflow-hidden group">
|
||||||
|
<FileText className="w-12 h-12 text-gray-400" />
|
||||||
|
<div className="text-center"><p className="text-sm font-medium">Document Uploaded</p><p className="text-xs text-gray-500 uppercase tracking-wider font-semibold mt-1">{selectedCell.status}</p></div>
|
||||||
|
<div className="absolute inset-0 bg-white/90 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-4">
|
||||||
|
<Button size="sm" onClick={() => window.open(selectedCell.documentUrl, '_blank')}><Eye className="w-4 h-4 mr-2" /> View</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { const link = document.createElement('a'); link.href = selectedCell.documentUrl; link.download = `${selectedCell.staffName}_${selectedCell.documentTypeName}`; link.click(); }}><Download className="w-4 h-4 mr-2" /> Download</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2"><Label className="text-xs font-semibold uppercase text-gray-500">Status</Label><div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
|
||||||
|
{selectedCell.status === DocumentStatus.VERIFIED ? <><ShieldCheck className="w-4 h-4 text-emerald-500" /><span className="text-sm font-medium text-emerald-600">Verified</span></> : selectedCell.status === DocumentStatus.UPLOADED ? <><FileCheck className="w-4 h-4 text-blue-500" /><span className="text-sm font-medium">Uploaded</span></> : <span className="text-sm font-medium">{selectedCell.status}</span>}
|
||||||
|
</div></div>
|
||||||
|
<div className="space-y-2"><Label className="text-xs font-semibold uppercase text-gray-500">Expiry Date</Label><div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
|
||||||
|
<Calendar className="w-4 h-4 text-blue-600" /><span className="text-sm font-medium">{selectedCell.expiryDate ? format(new Date(selectedCell.expiryDate), 'MMM d, yyyy') : 'No Expiry'}</span>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Upload Document</Label>
|
||||||
|
<div className="aspect-[16/9] rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center gap-4 hover:bg-gray-100 transition-colors cursor-pointer" onClick={() => document.getElementById('file-upload')?.click()}>
|
||||||
|
{selectedFile ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<FileCheck className="w-12 h-12 text-emerald-500" />
|
||||||
|
<div className="text-center"><p className="text-sm font-medium">{selectedFile.name}</p><p className="text-xs text-gray-500 mt-1">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p></div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setSelectedFile(null); }}><X className="w-4 h-4 mr-1" /> Remove</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<><div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center"><Upload className="w-6 h-6 text-blue-600" /></div><div className="text-center"><p className="text-sm font-medium">Click or drag to upload</p><p className="text-xs text-gray-500 mt-1">PDF, JPG or PNG (max 5MB)</p></div></>
|
||||||
|
)}
|
||||||
|
<input id="file-upload" type="file" accept=".pdf,.jpg,.jpeg,.png" onChange={handleFileSelect} className="hidden" />
|
||||||
|
</div>
|
||||||
|
{uploading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500"><span>Uploading...</span><span>{Math.round(uploadProgress)}%</span></div>
|
||||||
|
<div className="h-2 bg-gray-200 rounded-full overflow-hidden"><div className="h-full bg-blue-600 transition-all duration-300" style={{ width: `${uploadProgress}%` }} /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Expiry Date (Optional)</Label>
|
||||||
|
<Input type="date" className="bg-white border-gray-200" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} min={new Date().toISOString().split('T')[0]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0 flex-col sm:flex-row">
|
||||||
|
{selectedCell?.id ? (
|
||||||
|
<>
|
||||||
|
{isAdmin && selectedCell.status !== DocumentStatus.VERIFIED && (
|
||||||
|
<Button variant="outline" className="text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 border-emerald-200" onClick={handleVerify}><ShieldCheck className="w-4 h-4 mr-2" /> Verify Document</Button>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Button variant="outline" className="text-amber-600 hover:text-amber-700 hover:bg-amber-50 border-amber-200" onClick={() => { const reason = window.prompt("Enter reason for flagging this document:"); if (reason) { setFlagReason(reason); handleFlagIssue(); } }}><Flag className="w-4 h-4 mr-2" /> Flag Issue</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button className="w-full bg-blue-600 hover:bg-blue-700" onClick={handleUpload} disabled={!selectedFile || uploading}>{uploading ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Uploading...</> : <><Upload className="w-4 h-4 mr-2" /> Complete Upload</>}</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={showVerificationModal} onOpenChange={setShowVerificationModal}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader><DialogTitle className="flex items-center gap-2 text-xl"><div className="w-8 h-8 rounded-lg bg-emerald-50 flex items-center justify-center"><ShieldCheck className="w-5 h-5 text-emerald-600" /></div>Verify Document Authenticity</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<Alert className="border-emerald-200 bg-emerald-50"><ShieldCheck className="h-4 w-4 text-emerald-600" /><AlertDescription className="text-emerald-800">By verifying this document, you confirm that you have reviewed it for authenticity and validity.</AlertDescription></Alert>
|
||||||
|
<div className="space-y-2"><Label className="text-sm font-medium">Verification Notes (Optional)</Label><Textarea placeholder="Add any notes..." className="min-h-[100px] resize-none" value={verificationNotes} onChange={(e) => setVerificationNotes(e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2"><Button variant="outline" onClick={() => setShowVerificationModal(false)}>Cancel</Button><Button className="bg-emerald-600 hover:bg-emerald-700" onClick={confirmVerification}><ShieldCheck className="w-4 h-4 mr-2" />Confirm Verification</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ 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';
|
import ComplianceDashboard from './features/workforce/compliance/ComplianceDashboard';
|
||||||
|
import DocumentVault from './features/workforce/documents/DocumentVault';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppRoutes Component
|
* AppRoutes Component
|
||||||
@@ -99,6 +99,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<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 />} />
|
<Route path="/compliance" element={<ComplianceDashboard />} />
|
||||||
|
<Route path="/documents" element={<DocumentVault />} />
|
||||||
{/* 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