703 lines
29 KiB
JavaScript
703 lines
29 KiB
JavaScript
import React, { useState, useMemo } from "react";
|
|
import { base44 } from "@/api/base44Client";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import {
|
|
FileText, Search, Plus, Clock, CheckCircle2, XCircle, AlertTriangle,
|
|
Upload, Eye, Download, Activity, Filter, Users, ChevronRight
|
|
} from "lucide-react";
|
|
import { format, differenceInDays, parseISO } from "date-fns";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
|
|
const DOCUMENT_TYPES = [
|
|
"W-4 Form",
|
|
"I-9 Form",
|
|
"State Tax Form",
|
|
"Direct Deposit",
|
|
"ID Copy",
|
|
"SSN Card",
|
|
"Work Permit"
|
|
];
|
|
|
|
const STATUS_CONFIG = {
|
|
uploaded: { color: "bg-cyan-400", icon: FileText, label: "Uploaded" },
|
|
pending: { color: "bg-amber-300", icon: Clock, label: "Pending" },
|
|
expiring: { color: "bg-yellow-400", icon: Clock, label: "Expiring" },
|
|
expired: { color: "bg-red-400", icon: XCircle, label: "Expired" },
|
|
rejected: { color: "bg-red-500", icon: XCircle, label: "Rejected" },
|
|
missing: { color: "bg-slate-200", icon: Plus, label: "Missing" },
|
|
};
|
|
|
|
export default function EmployeeDocuments() {
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
|
const [selectedCell, setSelectedCell] = useState(null);
|
|
const [showActivityPanel, setShowActivityPanel] = useState(false);
|
|
const [docTypeFilter, setDocTypeFilter] = useState("all");
|
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
|
|
const { data: user } = useQuery({
|
|
queryKey: ['current-user-docs'],
|
|
queryFn: () => base44.auth.me(),
|
|
});
|
|
|
|
const { data: documents = [] } = useQuery({
|
|
queryKey: ['employee-documents'],
|
|
queryFn: () => base44.entities.EmployeeDocument.list(),
|
|
initialData: [],
|
|
});
|
|
|
|
const { data: staff = [] } = useQuery({
|
|
queryKey: ['staff-for-docs'],
|
|
queryFn: () => base44.entities.Staff.list(),
|
|
initialData: [],
|
|
});
|
|
|
|
const userRole = user?.user_role || user?.role || "admin";
|
|
const isVendor = userRole === "vendor";
|
|
|
|
// Filter staff by vendor
|
|
const filteredStaff = useMemo(() => {
|
|
let result = staff;
|
|
if (isVendor && user?.vendor_id) {
|
|
result = result.filter(s => s.vendor_id === user.vendor_id);
|
|
}
|
|
if (searchTerm) {
|
|
result = result.filter(s =>
|
|
s.employee_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
}
|
|
return result;
|
|
}, [staff, isVendor, user, searchTerm]);
|
|
|
|
// Build document matrix
|
|
const documentMatrix = useMemo(() => {
|
|
const matrix = {};
|
|
|
|
filteredStaff.forEach(emp => {
|
|
matrix[emp.id] = {
|
|
employee: emp,
|
|
documents: {},
|
|
completionRate: 0,
|
|
};
|
|
|
|
DOCUMENT_TYPES.forEach(type => {
|
|
matrix[emp.id].documents[type] = null;
|
|
});
|
|
});
|
|
|
|
documents.forEach(doc => {
|
|
if (matrix[doc.employee_id]) {
|
|
// Calculate status based on expiry
|
|
let status = doc.status || "uploaded";
|
|
if (doc.expiry_date) {
|
|
const days = differenceInDays(parseISO(doc.expiry_date), new Date());
|
|
if (days < 0) status = "expired";
|
|
else if (days <= 30) status = "expiring";
|
|
}
|
|
matrix[doc.employee_id].documents[doc.document_type] = { ...doc, status };
|
|
}
|
|
});
|
|
|
|
// Calculate completion rates
|
|
Object.values(matrix).forEach(row => {
|
|
const uploaded = Object.values(row.documents).filter(d => d && d.status === "uploaded").length;
|
|
row.completionRate = Math.round((uploaded / DOCUMENT_TYPES.length) * 100);
|
|
});
|
|
|
|
return matrix;
|
|
}, [filteredStaff, documents]);
|
|
|
|
// Stats
|
|
const stats = useMemo(() => {
|
|
let total = 0, uploaded = 0, pending = 0, expiring = 0, expired = 0;
|
|
|
|
Object.values(documentMatrix).forEach(row => {
|
|
Object.values(row.documents).forEach(doc => {
|
|
total++;
|
|
if (!doc) return;
|
|
if (doc.status === "uploaded") uploaded++;
|
|
else if (doc.status === "pending") pending++;
|
|
else if (doc.status === "expiring") expiring++;
|
|
else if (doc.status === "expired") expired++;
|
|
});
|
|
});
|
|
|
|
const missing = total - uploaded - pending - expiring - expired;
|
|
return { total, uploaded, pending, expiring, expired, missing };
|
|
}, [documentMatrix]);
|
|
|
|
// Save document
|
|
const saveMutation = useMutation({
|
|
mutationFn: async (data) => {
|
|
if (data.id) {
|
|
return base44.entities.EmployeeDocument.update(data.id, data);
|
|
}
|
|
return base44.entities.EmployeeDocument.create(data);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['employee-documents'] });
|
|
setShowUploadModal(false);
|
|
setSelectedCell(null);
|
|
toast({ title: "✅ Document saved" });
|
|
},
|
|
});
|
|
|
|
const handleCellClick = (employeeId, docType, existingDoc) => {
|
|
const emp = documentMatrix[employeeId]?.employee;
|
|
setSelectedCell({
|
|
employee_id: employeeId,
|
|
employee_name: emp?.employee_name,
|
|
vendor_id: emp?.vendor_id,
|
|
vendor_name: emp?.vendor_name,
|
|
document_type: docType,
|
|
...existingDoc,
|
|
});
|
|
setShowUploadModal(true);
|
|
};
|
|
|
|
const renderCell = (doc, employeeId, docType) => {
|
|
const status = doc?.status || "missing";
|
|
const config = STATUS_CONFIG[status];
|
|
const daysUntilExpiry = doc?.expiry_date ? differenceInDays(parseISO(doc.expiry_date), new Date()) : null;
|
|
const isExpirableDoc = docType === "Work Permit" || docType === "ID Copy";
|
|
|
|
return (
|
|
<button
|
|
onClick={() => handleCellClick(employeeId, docType, doc)}
|
|
className={`w-full h-14 rounded-lg flex flex-col items-center justify-center transition-all hover:scale-105 hover:shadow-md ${config.color} relative group`}
|
|
>
|
|
{status === "missing" ? (
|
|
<Plus className="w-5 h-5 text-slate-400" />
|
|
) : status === "uploaded" ? (
|
|
<>
|
|
<div className="flex items-center gap-1">
|
|
<FileText className="w-4 h-4 text-white" />
|
|
<span className="text-[9px] text-white font-bold bg-cyan-600 px-1 rounded">✓</span>
|
|
</div>
|
|
{isExpirableDoc && doc?.expiry_date && (
|
|
<span className="text-[9px] text-white/90 mt-0.5 font-medium">
|
|
{format(parseISO(doc.expiry_date), 'MM/dd/yy')}
|
|
</span>
|
|
)}
|
|
</>
|
|
) : status === "expiring" ? (
|
|
<>
|
|
<Clock className="w-4 h-4 text-amber-700" />
|
|
<span className="text-[9px] text-amber-800 mt-0.5 font-bold">
|
|
{daysUntilExpiry}d left
|
|
</span>
|
|
</>
|
|
) : status === "expired" ? (
|
|
<>
|
|
<XCircle className="w-4 h-4 text-white" />
|
|
<span className="text-[9px] text-white/90 mt-0.5 font-medium">Expired</span>
|
|
</>
|
|
) : status === "pending" ? (
|
|
<Clock className="w-5 h-5 text-amber-700" />
|
|
) : (
|
|
<config.icon className="w-5 h-5" />
|
|
)}
|
|
|
|
{/* Hover tooltip for expirable docs */}
|
|
{isExpirableDoc && doc?.expiry_date && (
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-slate-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
|
Expires: {format(parseISO(doc.expiry_date), 'MMM d, yyyy')}
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// Calculate column completion percentages
|
|
const columnStats = useMemo(() => {
|
|
const stats = {};
|
|
DOCUMENT_TYPES.forEach(type => {
|
|
const total = Object.keys(documentMatrix).length;
|
|
const uploaded = Object.values(documentMatrix).filter(
|
|
row => row.documents[type]?.status === "uploaded"
|
|
).length;
|
|
stats[type] = total > 0 ? Math.round((uploaded / total) * 100) : 0;
|
|
});
|
|
return stats;
|
|
}, [documentMatrix]);
|
|
|
|
// Filter matrix by status and doc type
|
|
const filteredMatrix = useMemo(() => {
|
|
let result = { ...documentMatrix };
|
|
|
|
// Filter by status
|
|
if (statusFilter !== "all") {
|
|
result = Object.fromEntries(
|
|
Object.entries(result).filter(([empId, row]) => {
|
|
const docs = Object.values(row.documents);
|
|
if (statusFilter === "uploaded") return docs.some(d => d?.status === "uploaded");
|
|
if (statusFilter === "expiring") return docs.some(d => d?.status === "expiring");
|
|
if (statusFilter === "expired") return docs.some(d => d?.status === "expired");
|
|
if (statusFilter === "missing") return docs.some(d => !d);
|
|
if (statusFilter === "pending") return docs.some(d => d?.status === "pending");
|
|
return true;
|
|
})
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}, [documentMatrix, statusFilter, docTypeFilter]);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-100 via-blue-50 to-slate-100 p-4 md:p-6">
|
|
<div className="max-w-[1800px] mx-auto">
|
|
{/* Header */}
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
|
|
<FileText className="w-6 h-6 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900">Employee Documents</h1>
|
|
<p className="text-sm text-slate-500">Track and manage all required documents</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex -space-x-2">
|
|
{filteredStaff.slice(0, 3).map((emp, i) => (
|
|
<Avatar key={emp.id} className="w-8 h-8 border-2 border-white">
|
|
<AvatarFallback className="bg-gradient-to-br from-blue-400 to-purple-500 text-white text-xs">
|
|
{emp.employee_name?.charAt(0)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
))}
|
|
{filteredStaff.length > 3 && (
|
|
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-bold text-slate-600">
|
|
+{filteredStaff.length - 3}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
className={`gap-2 ${showActivityPanel ? 'bg-orange-100 border-orange-300 text-orange-700' : ''}`}
|
|
onClick={() => setShowActivityPanel(!showActivityPanel)}
|
|
>
|
|
<Activity className="w-4 h-4" />
|
|
Activity
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Bar - Clickable */}
|
|
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 mb-6">
|
|
<Card
|
|
className={`border-0 shadow-sm bg-white cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'all' ? 'ring-2 ring-slate-400' : ''}`}
|
|
onClick={() => setStatusFilter('all')}
|
|
>
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-slate-100 rounded-lg flex items-center justify-center">
|
|
<Users className="w-4 h-4 text-slate-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-bold text-slate-900">{filteredStaff.length}</p>
|
|
<p className="text-[10px] text-slate-500">Employees</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card
|
|
className={`border-0 shadow-sm bg-cyan-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'uploaded' ? 'ring-2 ring-cyan-400' : ''}`}
|
|
onClick={() => setStatusFilter('uploaded')}
|
|
>
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-cyan-400 rounded-lg flex items-center justify-center">
|
|
<CheckCircle2 className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-bold text-cyan-700">{stats.uploaded}</p>
|
|
<p className="text-[10px] text-cyan-600">Uploaded</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card
|
|
className={`border-0 shadow-sm bg-amber-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'pending' ? 'ring-2 ring-amber-400' : ''}`}
|
|
onClick={() => setStatusFilter('pending')}
|
|
>
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-amber-400 rounded-lg flex items-center justify-center">
|
|
<Clock className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-bold text-amber-700">{stats.pending}</p>
|
|
<p className="text-[10px] text-amber-600">Pending</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card
|
|
className={`border-0 shadow-sm bg-yellow-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'expiring' ? 'ring-2 ring-yellow-400' : ''}`}
|
|
onClick={() => setStatusFilter('expiring')}
|
|
>
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-yellow-400 rounded-lg flex items-center justify-center">
|
|
<AlertTriangle className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-bold text-yellow-700">{stats.expiring}</p>
|
|
<p className="text-[10px] text-yellow-600">Expiring</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card
|
|
className={`border-0 shadow-sm bg-red-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'expired' ? 'ring-2 ring-red-400' : ''}`}
|
|
onClick={() => setStatusFilter('expired')}
|
|
>
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-red-400 rounded-lg flex items-center justify-center">
|
|
<XCircle className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-bold text-red-700">{stats.expired}</p>
|
|
<p className="text-[10px] text-red-600">Expired</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card
|
|
className={`border-0 shadow-sm bg-slate-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'missing' ? 'ring-2 ring-slate-400' : ''}`}
|
|
onClick={() => setStatusFilter('missing')}
|
|
>
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-slate-300 rounded-lg flex items-center justify-center">
|
|
<Plus className="w-4 h-4 text-slate-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-bold text-slate-700">{stats.missing}</p>
|
|
<p className="text-[10px] text-slate-500">Missing</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Main Document Matrix */}
|
|
<Card className="border-0 shadow-xl rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
{/* Search & Filter Bar */}
|
|
<div className="p-4 border-b border-slate-100 bg-white flex flex-wrap items-center gap-3">
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
<Input
|
|
placeholder="Search employees..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10 bg-slate-50 border-0 h-10"
|
|
/>
|
|
</div>
|
|
|
|
{/* Document Type Filter */}
|
|
<Select value={docTypeFilter} onValueChange={setDocTypeFilter}>
|
|
<SelectTrigger className="w-[160px] h-10">
|
|
<SelectValue placeholder="Doc Type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Documents</SelectItem>
|
|
{DOCUMENT_TYPES.map(type => (
|
|
<SelectItem key={type} value={type}>
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="w-3 h-3 text-blue-500" />
|
|
{type}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Status Filter */}
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-[150px] h-10">
|
|
<SelectValue placeholder="Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Status</SelectItem>
|
|
<SelectItem value="uploaded">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-cyan-400" />
|
|
Uploaded
|
|
</div>
|
|
</SelectItem>
|
|
<SelectItem value="expiring">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-yellow-400" />
|
|
Expiring Soon
|
|
</div>
|
|
</SelectItem>
|
|
<SelectItem value="expired">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-red-400" />
|
|
Expired
|
|
</div>
|
|
</SelectItem>
|
|
<SelectItem value="pending">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-amber-400" />
|
|
Pending
|
|
</div>
|
|
</SelectItem>
|
|
<SelectItem value="missing">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-slate-300" />
|
|
Missing
|
|
</div>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{(statusFilter !== "all" || docTypeFilter !== "all") && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => { setStatusFilter("all"); setDocTypeFilter("all"); }}
|
|
className="text-slate-500 hover:text-slate-700"
|
|
>
|
|
Clear filters
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Matrix Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="bg-slate-50">
|
|
<th className="text-left p-4 font-semibold text-slate-700 min-w-[200px] sticky left-0 bg-slate-50 z-10">
|
|
Users
|
|
</th>
|
|
{DOCUMENT_TYPES.map(type => (
|
|
<th key={type} className="p-4 min-w-[130px]">
|
|
<div className="text-center">
|
|
<p className="font-semibold text-slate-700 text-sm mb-2">{type}</p>
|
|
<div className="flex gap-0.5 justify-center">
|
|
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 25 ? 'bg-cyan-400' : 'bg-slate-200'}`} />
|
|
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 50 ? 'bg-amber-400' : 'bg-slate-200'}`} />
|
|
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 75 ? 'bg-red-400' : 'bg-slate-200'}`} />
|
|
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 100 ? 'bg-green-400' : 'bg-slate-200'}`} />
|
|
</div>
|
|
</div>
|
|
</th>
|
|
))}
|
|
<th className="p-4 w-12">
|
|
<button className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 transition-colors">
|
|
<Plus className="w-4 h-4" />
|
|
</button>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{Object.entries(filteredMatrix).map(([empId, row], idx) => (
|
|
<tr key={empId} className={`border-t border-slate-100 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
|
|
<td className="p-4 sticky left-0 bg-inherit z-10">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className="w-10 h-10 border-2 border-white shadow">
|
|
<AvatarFallback className="bg-gradient-to-br from-blue-400 to-purple-500 text-white font-bold">
|
|
{row.employee.employee_name?.charAt(0)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-semibold text-slate-900 truncate">{row.employee.employee_name}</p>
|
|
<div className="flex items-center gap-2">
|
|
<Progress value={row.completionRate} className="h-1.5 w-16" />
|
|
<span className="text-xs text-slate-500">{row.completionRate}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
{DOCUMENT_TYPES.map(type => (
|
|
<td key={type} className={`p-2 ${docTypeFilter !== "all" && docTypeFilter !== type ? 'opacity-30' : ''}`}>
|
|
{renderCell(row.documents[type], empId, type)}
|
|
</td>
|
|
))}
|
|
<td className="p-2">
|
|
{row.completionRate === 100 && (
|
|
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
|
|
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{filteredStaff.length === 0 && (
|
|
<div className="p-12 text-center">
|
|
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-200" />
|
|
<p className="text-slate-500">No employees found</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-slate-100 bg-slate-50 text-center">
|
|
<p className="text-sm text-slate-500">
|
|
to make sure everything is always up to date
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Upload Modal */}
|
|
<Dialog open={showUploadModal} onOpenChange={setShowUploadModal}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<FileText className="w-5 h-5 text-blue-600" />
|
|
{selectedCell?.id ? 'Update' : 'Upload'} Document
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
{selectedCell && (
|
|
<DocumentUploadForm
|
|
data={selectedCell}
|
|
onSave={(data) => saveMutation.mutate(data)}
|
|
onCancel={() => { setShowUploadModal(false); setSelectedCell(null); }}
|
|
isLoading={saveMutation.isPending}
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Activity Panel */}
|
|
{showActivityPanel && (
|
|
<div className="fixed right-0 top-0 h-full w-80 bg-white shadow-2xl border-l border-slate-200 z-50 p-6 overflow-y-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-bold text-lg">Recent Activity</h3>
|
|
<Button variant="ghost" size="sm" onClick={() => setShowActivityPanel(false)}>
|
|
<XCircle className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{[1,2,3,4,5].map(i => (
|
|
<div key={i} className="flex items-start gap-3 p-3 bg-slate-50 rounded-lg">
|
|
<div className="w-8 h-8 rounded-full bg-cyan-400 flex items-center justify-center">
|
|
<Upload className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-900">Document uploaded</p>
|
|
<p className="text-xs text-slate-500">W-4 Form • 2 hours ago</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DocumentUploadForm({ data, onSave, onCancel, isLoading }) {
|
|
const [formData, setFormData] = useState({
|
|
employee_id: data.employee_id || "",
|
|
employee_name: data.employee_name || "",
|
|
vendor_id: data.vendor_id || "",
|
|
vendor_name: data.vendor_name || "",
|
|
document_type: data.document_type || "",
|
|
status: data.status || "uploaded",
|
|
expiry_date: data.expiry_date || "",
|
|
document_url: data.document_url || "",
|
|
notes: data.notes || "",
|
|
...data,
|
|
});
|
|
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
const handleFileUpload = async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setUploading(true);
|
|
try {
|
|
const result = await base44.integrations.Core.UploadFile({ file });
|
|
setFormData(prev => ({ ...prev, document_url: result.file_url, status: "uploaded" }));
|
|
} catch (error) {
|
|
console.error("Upload failed:", error);
|
|
}
|
|
setUploading(false);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="p-4 bg-slate-50 rounded-lg">
|
|
<p className="text-sm text-slate-600">Employee</p>
|
|
<p className="font-semibold text-slate-900">{formData.employee_name}</p>
|
|
<p className="text-sm text-blue-600 mt-1">{formData.document_type}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-sm font-medium">Upload Document</Label>
|
|
<div className="mt-2 border-2 border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue-400 transition-colors">
|
|
<input
|
|
type="file"
|
|
accept=".pdf,.jpg,.jpeg,.png"
|
|
onChange={handleFileUpload}
|
|
className="hidden"
|
|
id="doc-upload"
|
|
/>
|
|
<label htmlFor="doc-upload" className="cursor-pointer">
|
|
{uploading ? (
|
|
<div className="animate-pulse">Uploading...</div>
|
|
) : formData.document_url ? (
|
|
<div className="flex items-center justify-center gap-2 text-green-600">
|
|
<CheckCircle2 className="w-6 h-6" />
|
|
<span>Document uploaded</span>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<Upload className="w-8 h-8 mx-auto text-slate-400 mb-2" />
|
|
<p className="text-sm text-slate-600">Click to upload</p>
|
|
<p className="text-xs text-slate-400">PDF, JPG, PNG</p>
|
|
</div>
|
|
)}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-sm font-medium">Expiry Date (if applicable)</Label>
|
|
<Input
|
|
type="date"
|
|
value={formData.expiry_date}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))}
|
|
className="mt-1.5"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
|
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
|
<Button
|
|
onClick={() => onSave(formData)}
|
|
disabled={isLoading}
|
|
className="bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
{isLoading ? "Saving..." : "Save Document"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |