768 lines
32 KiB
JavaScript
768 lines
32 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 { 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="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>
|
|
|
|
{/* 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>
|
|
);
|
|
} |