other modifications days ago
This commit is contained in:
@@ -1,18 +1,767 @@
|
||||
import React from "react";
|
||||
import { Award } from "lucide-react";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Award, Search, Plus, AlertTriangle, CheckCircle2, Clock, XCircle,
|
||||
Download, Send, Eye, Edit2, ShieldCheck, FileText, Sparkles,
|
||||
Calendar, User, Building2, ChevronRight, Filter, Bell, TrendingUp
|
||||
} from "lucide-react";
|
||||
import { format, differenceInDays, parseISO } from "date-fns";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
const REQUIRED_CERTIFICATIONS = ["Background Check", "RBS", "Food Handler"];
|
||||
|
||||
const CERT_CONFIG = {
|
||||
"Background Check": {
|
||||
color: "from-purple-500 to-purple-600",
|
||||
bgColor: "bg-purple-50",
|
||||
textColor: "text-purple-700",
|
||||
borderColor: "border-purple-200",
|
||||
icon: ShieldCheck,
|
||||
description: "Criminal background verification"
|
||||
},
|
||||
"RBS": {
|
||||
color: "from-blue-500 to-blue-600",
|
||||
bgColor: "bg-blue-50",
|
||||
textColor: "text-blue-700",
|
||||
borderColor: "border-blue-200",
|
||||
icon: Award,
|
||||
description: "Responsible Beverage Server"
|
||||
},
|
||||
"Food Handler": {
|
||||
color: "from-emerald-500 to-emerald-600",
|
||||
bgColor: "bg-emerald-50",
|
||||
textColor: "text-emerald-700",
|
||||
borderColor: "border-emerald-200",
|
||||
icon: FileText,
|
||||
description: "Food safety certification"
|
||||
},
|
||||
};
|
||||
|
||||
export default function Certification() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [certTypeFilter, setCertTypeFilter] = useState("all");
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingCert, setEditingCert] = useState(null);
|
||||
const [showReportModal, setShowReportModal] = useState(false);
|
||||
const [selectedEmployee, setSelectedEmployee] = useState(null);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-cert'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: certifications = [] } = useQuery({
|
||||
queryKey: ['certifications'],
|
||||
queryFn: () => base44.entities.Certification.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: staff = [] } = useQuery({
|
||||
queryKey: ['staff-for-cert'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const userRole = user?.user_role || user?.role || "admin";
|
||||
const isVendor = userRole === "vendor";
|
||||
const isProcurement = userRole === "procurement";
|
||||
|
||||
const calculateStatus = (expiryDate) => {
|
||||
if (!expiryDate) return "pending";
|
||||
const days = differenceInDays(parseISO(expiryDate), new Date());
|
||||
if (days < 0) return "expired";
|
||||
if (days <= 30) return "expiring_soon";
|
||||
return "current";
|
||||
};
|
||||
|
||||
const processedCerts = useMemo(() => {
|
||||
return certifications.map(cert => ({
|
||||
...cert,
|
||||
days_until_expiry: cert.expiry_date ? differenceInDays(parseISO(cert.expiry_date), new Date()) : null,
|
||||
status: calculateStatus(cert.expiry_date),
|
||||
}));
|
||||
}, [certifications]);
|
||||
|
||||
const employeeCertMap = useMemo(() => {
|
||||
const map = {};
|
||||
|
||||
staff.forEach(s => {
|
||||
map[s.id] = {
|
||||
employee_id: s.id,
|
||||
employee_name: s.employee_name,
|
||||
vendor_id: s.vendor_id,
|
||||
vendor_name: s.vendor_name,
|
||||
position: s.position,
|
||||
certifications: { "Background Check": null, "RBS": null, "Food Handler": null },
|
||||
allCurrent: false,
|
||||
hasExpired: false,
|
||||
hasExpiringSoon: false,
|
||||
missingCount: 3,
|
||||
canWork: false,
|
||||
complianceScore: 0,
|
||||
};
|
||||
});
|
||||
|
||||
processedCerts.forEach(cert => {
|
||||
const key = cert.employee_id;
|
||||
if (!map[key]) {
|
||||
map[key] = {
|
||||
employee_id: cert.employee_id,
|
||||
employee_name: cert.employee_name,
|
||||
vendor_id: cert.vendor_id,
|
||||
vendor_name: cert.vendor_name,
|
||||
position: "",
|
||||
certifications: { "Background Check": null, "RBS": null, "Food Handler": null },
|
||||
allCurrent: false,
|
||||
hasExpired: false,
|
||||
hasExpiringSoon: false,
|
||||
missingCount: 3,
|
||||
canWork: false,
|
||||
complianceScore: 0,
|
||||
};
|
||||
}
|
||||
if (REQUIRED_CERTIFICATIONS.includes(cert.certification_type)) {
|
||||
map[key].certifications[cert.certification_type] = cert;
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(map).forEach(emp => {
|
||||
const certs = Object.values(emp.certifications);
|
||||
const validCerts = certs.filter(c => c && c.status === "current");
|
||||
const expiredCerts = certs.filter(c => c && c.status === "expired");
|
||||
const expiringSoonCerts = certs.filter(c => c && c.status === "expiring_soon");
|
||||
const missingCerts = certs.filter(c => !c);
|
||||
|
||||
emp.allCurrent = validCerts.length === 3;
|
||||
emp.hasExpired = expiredCerts.length > 0;
|
||||
emp.hasExpiringSoon = expiringSoonCerts.length > 0;
|
||||
emp.missingCount = missingCerts.length;
|
||||
emp.canWork = validCerts.length === 3 || (validCerts.length + expiringSoonCerts.length === 3);
|
||||
emp.complianceScore = Math.round(((validCerts.length + expiringSoonCerts.length * 0.5) / 3) * 100);
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [processedCerts, staff]);
|
||||
|
||||
const employeeList = Object.values(employeeCertMap);
|
||||
|
||||
const filteredEmployees = useMemo(() => {
|
||||
let filtered = employeeList;
|
||||
|
||||
if (isVendor && user?.vendor_id) {
|
||||
filtered = filtered.filter(e => e.vendor_id === user.vendor_id);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(e =>
|
||||
e.employee_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "compliant") filtered = filtered.filter(e => e.allCurrent);
|
||||
else if (activeTab === "expiring") filtered = filtered.filter(e => e.hasExpiringSoon);
|
||||
else if (activeTab === "expired") filtered = filtered.filter(e => e.hasExpired);
|
||||
else if (activeTab === "incomplete") filtered = filtered.filter(e => e.missingCount > 0);
|
||||
|
||||
if (certTypeFilter !== "all") {
|
||||
filtered = filtered.filter(e => {
|
||||
const cert = e.certifications[certTypeFilter];
|
||||
return cert !== null;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [employeeList, searchTerm, activeTab, certTypeFilter, isVendor, user]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = employeeList.length;
|
||||
const compliant = employeeList.filter(e => e.allCurrent).length;
|
||||
const expiring = employeeList.filter(e => e.hasExpiringSoon).length;
|
||||
const expired = employeeList.filter(e => e.hasExpired).length;
|
||||
const incomplete = employeeList.filter(e => e.missingCount > 0).length;
|
||||
const avgCompliance = total > 0 ? Math.round(employeeList.reduce((sum, e) => sum + e.complianceScore, 0) / total) : 0;
|
||||
return { total, compliant, expiring, expired, incomplete, avgCompliance };
|
||||
}, [employeeList]);
|
||||
|
||||
const saveCertMutation = useMutation({
|
||||
mutationFn: async (data) => {
|
||||
if (data.id) return base44.entities.Certification.update(data.id, data);
|
||||
return base44.entities.Certification.create(data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certifications'] });
|
||||
setShowAddModal(false);
|
||||
setEditingCert(null);
|
||||
toast({ title: "✅ Certification saved" });
|
||||
},
|
||||
});
|
||||
|
||||
const sendExpiryAlert = async (cert) => {
|
||||
try {
|
||||
await base44.integrations.Core.SendEmail({
|
||||
to: user?.email || "admin@company.com",
|
||||
subject: `⚠️ Certification Expiring: ${cert.employee_name} - ${cert.certification_type}`,
|
||||
body: `<h2>Certification Expiring Alert</h2>
|
||||
<p><strong>Employee:</strong> ${cert.employee_name}</p>
|
||||
<p><strong>Certification:</strong> ${cert.certification_type}</p>
|
||||
<p><strong>Expiry Date:</strong> ${format(parseISO(cert.expiry_date), 'MMM d, yyyy')}</p>
|
||||
<p><strong>Days Until Expiry:</strong> ${cert.days_until_expiry} days</p>`
|
||||
});
|
||||
toast({ title: "✅ Alert sent" });
|
||||
} catch (error) {
|
||||
toast({ title: "Failed to send alert", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const sendComplianceReport = async (clientEmail) => {
|
||||
const compliantEmployees = employeeList.filter(e => e.allCurrent);
|
||||
try {
|
||||
await base44.integrations.Core.SendEmail({
|
||||
to: clientEmail,
|
||||
subject: "Staff Compliance Report",
|
||||
body: `<h2>Staff Compliance Report</h2>
|
||||
<p>Generated: ${format(new Date(), 'MMM d, yyyy')}</p>
|
||||
<p><strong>Total Staff:</strong> ${stats.total}</p>
|
||||
<p><strong>Fully Compliant:</strong> ${stats.compliant}</p>
|
||||
<p><strong>Average Compliance:</strong> ${stats.avgCompliance}%</p>
|
||||
<hr/><h3>Compliant Staff</h3>
|
||||
<ul>${compliantEmployees.map(e => `<li>${e.employee_name}</li>`).join('')}</ul>`
|
||||
});
|
||||
toast({ title: "✅ Report sent" });
|
||||
setShowReportModal(false);
|
||||
} catch (error) {
|
||||
toast({ title: "Failed to send report", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<Award className="w-8 h-8" />
|
||||
<h1 className="text-3xl font-bold">Certification</h1>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
|
||||
<div className="p-4 md:p-6 max-w-[1800px] mx-auto">
|
||||
{/* Hero Header */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-r from-[#0A39DF] via-blue-600 to-[#1C323E] rounded-2xl p-6 md:p-8 mb-6 text-white">
|
||||
<div className="absolute inset-0 opacity-50" style={{ backgroundImage: "url(\"data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E\")" }} />
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-12 h-12 bg-white/20 backdrop-blur rounded-xl flex items-center justify-center">
|
||||
<Award className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold">Certification Hub</h1>
|
||||
<p className="text-blue-100 text-sm">Track & manage workforce compliance</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" className="bg-white/20 hover:bg-white/30 text-white border-0" onClick={() => setShowReportModal(true)}>
|
||||
<Send className="w-4 h-4 mr-2" />Send Report
|
||||
</Button>
|
||||
{(isVendor || userRole === "admin") && (
|
||||
<Button className="bg-white text-blue-600 hover:bg-blue-50" onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />Add Certification
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mt-6">
|
||||
<div className="bg-white/10 backdrop-blur rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User className="w-4 h-4 text-blue-200" />
|
||||
<span className="text-xs text-blue-200">Total Staff</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-emerald-500/30 backdrop-blur rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-200" />
|
||||
<span className="text-xs text-emerald-200">Compliant</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.compliant}</p>
|
||||
</div>
|
||||
<div className="bg-amber-500/30 backdrop-blur rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-4 h-4 text-amber-200" />
|
||||
<span className="text-xs text-amber-200">Expiring 30d</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.expiring}</p>
|
||||
</div>
|
||||
<div className="bg-red-500/30 backdrop-blur rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<XCircle className="w-4 h-4 text-red-200" />
|
||||
<span className="text-xs text-red-200">Expired</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.expired}</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-blue-200" />
|
||||
<span className="text-xs text-blue-200">Avg. Compliance</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.avgCompliance}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center py-16 bg-white rounded-xl border">
|
||||
<Award className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<p className="text-slate-600">Certification management coming soon</p>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<Card className="border-0 shadow-lg mb-6 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<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 h-11 bg-slate-50 border-slate-200 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-shrink-0">
|
||||
<TabsList className="bg-slate-100 p-1 rounded-xl h-11">
|
||||
<TabsTrigger value="all" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow">
|
||||
All
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="compliant" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
||||
✓ Compliant
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="expiring" className="rounded-lg data-[state=active]:bg-amber-500 data-[state=active]:text-white">
|
||||
⏰ 30 Days
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="expired" className="rounded-lg data-[state=active]:bg-red-500 data-[state=active]:text-white">
|
||||
✗ Expired
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="incomplete" className="rounded-lg data-[state=active]:bg-slate-600 data-[state=active]:text-white">
|
||||
Missing
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Select value={certTypeFilter} onValueChange={setCertTypeFilter}>
|
||||
<SelectTrigger className="w-[180px] h-11 rounded-xl">
|
||||
<Filter className="w-4 h-4 mr-2 text-slate-400" />
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="Background Check">Background Check</SelectItem>
|
||||
<SelectItem value="RBS">RBS</SelectItem>
|
||||
<SelectItem value="Food Handler">Food Handler</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Employee Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<AnimatePresence>
|
||||
{filteredEmployees.length === 0 ? (
|
||||
<div className="col-span-full">
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Award className="w-16 h-16 mx-auto mb-4 text-slate-200" />
|
||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">No employees found</h3>
|
||||
<p className="text-slate-500">Try adjusting your search or filters</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
filteredEmployees.map((emp, idx) => (
|
||||
<motion.div
|
||||
key={emp.employee_id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
>
|
||||
<EmployeeCertCard
|
||||
employee={emp}
|
||||
onAddCert={(type) => {
|
||||
setEditingCert({
|
||||
employee_id: emp.employee_id,
|
||||
employee_name: emp.employee_name,
|
||||
vendor_id: emp.vendor_id,
|
||||
vendor_name: emp.vendor_name,
|
||||
certification_type: type,
|
||||
});
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
onEditCert={(cert) => {
|
||||
setEditingCert(cert);
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
onSendAlert={sendExpiryAlert}
|
||||
showVendor={isProcurement || userRole === "admin"}
|
||||
/>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Dialog open={showAddModal} onOpenChange={setShowAddModal}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Award className="w-5 h-5 text-blue-600" />
|
||||
{editingCert?.id ? 'Update' : 'Add'} Certification
|
||||
</DialogTitle>
|
||||
{editingCert?.employee_name && (
|
||||
<DialogDescription>For: {editingCert.employee_name}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<CertificationForm
|
||||
certification={editingCert}
|
||||
staff={staff}
|
||||
onSave={(data) => saveCertMutation.mutate(data)}
|
||||
onCancel={() => { setShowAddModal(false); setEditingCert(null); }}
|
||||
isLoading={saveCertMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Report Modal */}
|
||||
<Dialog open={showReportModal} onOpenChange={setShowReportModal}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Send className="w-5 h-5 text-blue-600" />
|
||||
Send Compliance Report
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ReportForm onSend={sendComplianceReport} onCancel={() => setShowReportModal(false)} stats={stats} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmployeeCertCard({ employee, onAddCert, onEditCert, onSendAlert, showVendor }) {
|
||||
const emp = employee;
|
||||
|
||||
return (
|
||||
<Card className={`border-0 shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden ${
|
||||
!emp.canWork ? 'ring-2 ring-red-200' : emp.allCurrent ? 'ring-2 ring-emerald-200' : ''
|
||||
}`}>
|
||||
<CardContent className="p-0">
|
||||
{/* Header */}
|
||||
<div className={`p-4 ${emp.allCurrent ? 'bg-gradient-to-r from-emerald-500 to-emerald-600' : emp.hasExpired ? 'bg-gradient-to-r from-red-500 to-red-600' : emp.hasExpiringSoon ? 'bg-gradient-to-r from-amber-500 to-amber-600' : 'bg-gradient-to-r from-slate-500 to-slate-600'} text-white`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="w-12 h-12 border-2 border-white/30">
|
||||
<AvatarFallback className="bg-white/20 text-white font-bold text-lg">
|
||||
{emp.employee_name?.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-lg truncate">{emp.employee_name}</h3>
|
||||
<p className="text-sm text-white/80 truncate">{emp.position || "Staff Member"}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-bold ${emp.canWork ? 'bg-white/20' : 'bg-white text-red-600'}`}>
|
||||
{emp.canWork ? "Can Work" : "Cannot Work"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance Progress */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-white/80">Compliance Score</span>
|
||||
<span className="font-bold">{emp.complianceScore}%</span>
|
||||
</div>
|
||||
<Progress value={emp.complianceScore} className="h-2 bg-white/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Certifications */}
|
||||
<div className="p-4 space-y-3">
|
||||
{REQUIRED_CERTIFICATIONS.map(type => {
|
||||
const cert = emp.certifications[type];
|
||||
const config = CERT_CONFIG[type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border-2 transition-all ${
|
||||
cert ? (
|
||||
cert.status === "current" ? `${config.bgColor} ${config.borderColor}` :
|
||||
cert.status === "expiring_soon" ? "bg-amber-50 border-amber-200" :
|
||||
"bg-red-50 border-red-200"
|
||||
) : "bg-slate-50 border-dashed border-slate-300"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
cert ? (
|
||||
cert.status === "current" ? `bg-gradient-to-br ${config.color} text-white` :
|
||||
cert.status === "expiring_soon" ? "bg-amber-500 text-white" :
|
||||
"bg-red-500 text-white"
|
||||
) : "bg-slate-200 text-slate-400"
|
||||
}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-slate-900 text-sm">{type}</p>
|
||||
{cert ? (
|
||||
<p className={`text-xs ${cert.status === "current" ? "text-slate-500" : cert.status === "expiring_soon" ? "text-amber-600" : "text-red-600"}`}>
|
||||
{cert.status === "expired" ? "Expired" : `Expires: ${format(parseISO(cert.expiry_date), 'MMM d, yyyy')}`}
|
||||
{cert.days_until_expiry !== null && cert.days_until_expiry >= 0 && ` (${cert.days_until_expiry}d)`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">Not uploaded</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{cert ? (
|
||||
<>
|
||||
{cert.status === "current" && (
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
)}
|
||||
{cert.status === "expiring_soon" && (
|
||||
<button
|
||||
onClick={() => onSendAlert(cert)}
|
||||
className="p-1.5 rounded-lg bg-amber-100 text-amber-600 hover:bg-amber-200 transition-colors"
|
||||
title="Send reminder"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{cert.status === "expired" && (
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => onEditCert(cert)}
|
||||
className="p-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onAddCert(type)}
|
||||
className="p-1.5 rounded-lg bg-blue-100 text-blue-600 hover:bg-blue-200 transition-colors"
|
||||
title="Add certification"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{showVendor && emp.vendor_name && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Building2 className="w-3 h-3" />
|
||||
<span>{emp.vendor_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CertificationForm({ certification, staff, onSave, onCancel, isLoading }) {
|
||||
const [formData, setFormData] = useState({
|
||||
employee_id: certification?.employee_id || "",
|
||||
employee_name: certification?.employee_name || "",
|
||||
vendor_id: certification?.vendor_id || "",
|
||||
vendor_name: certification?.vendor_name || "",
|
||||
certification_type: certification?.certification_type || "",
|
||||
issue_date: certification?.issue_date || "",
|
||||
expiry_date: certification?.expiry_date || "",
|
||||
issuer: certification?.issuer || "",
|
||||
certificate_number: certification?.certificate_number || "",
|
||||
notes: certification?.notes || "",
|
||||
...certification,
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
const handleStaffSelect = (staffId) => {
|
||||
const selectedStaff = staff.find(s => s.id === staffId);
|
||||
if (selectedStaff) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
employee_id: staffId,
|
||||
employee_name: selectedStaff.employee_name,
|
||||
vendor_id: selectedStaff.vendor_id,
|
||||
vendor_name: selectedStaff.vendor_name,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!certification?.employee_id && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Employee *</Label>
|
||||
<Select value={formData.employee_id} onValueChange={handleStaffSelect}>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue placeholder="Select employee" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{staff.map(s => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.employee_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Certification Type *</Label>
|
||||
<Select
|
||||
value={formData.certification_type}
|
||||
onValueChange={(v) => setFormData(prev => ({ ...prev, certification_type: v }))}
|
||||
disabled={!!certification?.certification_type}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REQUIRED_CERTIFICATIONS.map(type => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<div className="flex items-center gap-2">
|
||||
{React.createElement(CERT_CONFIG[type].icon, { className: "w-4 h-4" })}
|
||||
{type}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Issue Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.issue_date}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, issue_date: e.target.value }))}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Expiry Date *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.expiry_date}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))}
|
||||
className="mt-1.5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Issuing Authority</Label>
|
||||
<Input
|
||||
value={formData.issuer}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, issuer: e.target.value }))}
|
||||
placeholder="e.g., California ABC"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Certificate Number</Label>
|
||||
<Input
|
||||
value={formData.certificate_number}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, certificate_number: e.target.value }))}
|
||||
placeholder="Certificate ID"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" disabled={isLoading} className="bg-blue-600 hover:bg-blue-700">
|
||||
{isLoading ? "Saving..." : "Save Certification"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportForm({ onSend, onCancel, stats }) {
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gradient-to-br from-blue-50 to-slate-50 rounded-xl border border-blue-100">
|
||||
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-blue-600" />
|
||||
Report Preview
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<span className="text-slate-500 text-xs">Total Staff</span>
|
||||
<p className="font-bold text-lg text-slate-900">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 p-3 rounded-lg">
|
||||
<span className="text-emerald-600 text-xs">Compliant</span>
|
||||
<p className="font-bold text-lg text-emerald-700">{stats.compliant}</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 p-3 rounded-lg">
|
||||
<span className="text-amber-600 text-xs">Expiring Soon</span>
|
||||
<p className="font-bold text-lg text-amber-700">{stats.expiring}</p>
|
||||
</div>
|
||||
<div className="bg-red-50 p-3 rounded-lg">
|
||||
<span className="text-red-600 text-xs">Expired</span>
|
||||
<p className="font-bold text-lg text-red-700">{stats.expired}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Recipient Email *</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="client@company.com"
|
||||
className="mt-1.5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
<Button onClick={() => onSend(email)} disabled={!email} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="w-4 h-4 mr-2" />Send Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user