Files
Krow-workspace/frontend-web/src/pages/VendorCompliance.jsx
bwnyasse 554dc9f9e3 feat: Initialize monorepo structure and comprehensive documentation
This commit establishes the new monorepo architecture for the KROW Workforce platform.

Key changes include:
- Reorganized project into `frontend-web`, `mobile-apps`, `firebase`, `scripts`, and `secrets` directories.
- Updated `Makefile` to support the new monorepo layout and automate Base44 export integration.
- Fixed `scripts/prepare-export.js` for ES module compatibility and global component import resolution.
- Created and updated `CONTRIBUTING.md` for developer onboarding.
- Restructured, renamed, and translated all `docs/` files for clarity and consistency.
- Implemented an interactive internal launchpad with diagram viewing capabilities.
- Configured base Firebase project files (`firebase.json`, security rules).
- Updated `README.md` to reflect the new project structure and documentation overview.
2025-11-12 12:50:55 -05:00

1325 lines
55 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Shield, CheckCircle2, AlertTriangle, XCircle, Plus, Search,
Calendar, FileText, Upload, RefreshCw, Download, Award,
TrendingUp, Users, Bell, Zap, Loader2, Sparkles, FolderUp, X
} from "lucide-react";
import { differenceInDays, format, isBefore } from "date-fns";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
export default function VendorCompliance() {
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [typeFilter, setTypeFilter] = useState("all");
const [showAddDialog, setShowAddDialog] = useState(false);
const [showBulkImportDialog, setShowBulkImportDialog] = useState(false);
const [selectedEmployees, setSelectedEmployees] = useState([]);
const [employeeSearchTerm, setEmployeeSearchTerm] = useState("");
const [uploading, setUploading] = useState(false);
const [validating, setValidating] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState("");
const [aiValidationResult, setAiValidationResult] = useState(null);
const [bulkProcessing, setBulkProcessing] = useState(false);
const [bulkCertificates, setBulkCertificates] = useState([]);
const [isDragging, setIsDragging] = useState(false);
const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 });
const [newCert, setNewCert] = useState({
certification_name: "",
certification_type: "Legal",
issue_date: "",
expiry_date: "",
issuer: "",
certificate_number: "",
document_url: "",
owner: "",
expert_body: "",
is_required_for_role: false
});
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: user } = useQuery({
queryKey: ['current-user-compliance'],
queryFn: () => base44.auth.me(),
});
const { data: certifications = [], isLoading } = useQuery({
queryKey: ['certifications'],
queryFn: () => base44.entities.Certification.list('-expiry_date'),
initialData: [],
});
const { data: staff = [] } = useQuery({
queryKey: ['staff-for-certs'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const createCertMutation = useMutation({
mutationFn: (certData) => base44.entities.Certification.create(certData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
},
});
const resetForm = () => {
setNewCert({
certification_name: "",
certification_type: "Legal",
issue_date: "",
expiry_date: "",
issuer: "",
certificate_number: "",
document_url: "",
owner: "",
expert_body: "",
is_required_for_role: false
});
setSelectedEmployees([]);
setEmployeeSearchTerm("");
setUploadedFileName("");
setAiValidationResult(null);
};
const resetBulkImport = () => {
setBulkCertificates([]);
setBulkProcessing(false);
setProcessingProgress({ current: 0, total: 0 });
};
const filteredStaff = useMemo(() => {
if (!employeeSearchTerm) return staff;
const searchLower = employeeSearchTerm.toLowerCase();
return staff.filter(emp =>
emp.employee_name?.toLowerCase().includes(searchLower) ||
emp.position?.toLowerCase().includes(searchLower) ||
emp.email?.toLowerCase().includes(searchLower) ||
emp.contact_number?.includes(employeeSearchTerm)
);
}, [staff, employeeSearchTerm]);
const handleSelectAll = () => {
if (selectedEmployees.length === filteredStaff.length && filteredStaff.length > 0) {
setSelectedEmployees([]);
} else {
setSelectedEmployees(filteredStaff.map(s => s.id));
}
};
const formatDateForInput = (dateString) => {
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return "";
return format(date, 'yyyy-MM-dd');
} catch {
return "";
}
};
const findEmployeeByName = (certificateHolderName) => {
if (!certificateHolderName || !staff || staff.length === 0) return null;
const nameLower = certificateHolderName.toLowerCase().trim();
let match = staff.find(emp =>
emp.employee_name?.toLowerCase().trim() === nameLower
);
if (match) return { employee: match, confidence: 100 };
const namePartsInput = nameLower.split(' ').filter(p => p.length > 1);
match = staff.find(emp => {
const empNameLower = emp.employee_name?.toLowerCase().trim();
const empNameParts = empNameLower?.split(' ').filter(p => p.length > 1);
if (!empNameParts || empNameParts.length < 2) return false;
const firstMatch = namePartsInput[0] === empNameParts[0];
const lastMatch = namePartsInput[namePartsInput.length - 1] === empNameParts[empNameParts.length - 1];
return firstMatch && lastMatch;
});
if (match) return { employee: match, confidence: 95 };
if (namePartsInput.length >= 2) {
const firstInitial = namePartsInput[0].charAt(0);
const lastName = namePartsInput[namePartsInput.length - 1];
match = staff.find(emp => {
const empNameLower = emp.employee_name?.toLowerCase().trim();
const empNameParts = empNameLower?.split(' ');
if (!empNameParts || empNameParts.length < 2) return false;
const empFirstInitial = empNameParts[0].charAt(0);
const empLastName = empNameParts[empNameParts.length - 1];
return firstInitial === empFirstInitial && lastName === empLastName;
});
if (match) return { employee: match, confidence: 85 };
}
if (nameLower.includes(',')) {
const parts = nameLower.split(',').map(p => p.trim());
if (parts.length === 2) {
const reversedName = `${parts[1]} ${parts[0]}`;
match = staff.find(emp =>
emp.employee_name?.toLowerCase().trim() === reversedName
);
if (match) return { employee: match, confidence: 90 };
}
}
match = staff.find(emp => {
const empNameLower = emp.employee_name?.toLowerCase().trim();
if (!empNameLower) return false;
return namePartsInput.every(part => empNameLower.includes(part));
});
if (match) return { employee: match, confidence: 75 };
match = staff.find(emp => {
const empNameLower = emp.employee_name?.toLowerCase().trim();
return empNameLower?.includes(nameLower) || nameLower.includes(empNameLower || '');
});
if (match) return { employee: match, confidence: 60 };
return null;
};
const handleManualEmployeeSelection = (certIndex, employeeId) => {
const employee = staff.find(s => s.id === employeeId);
if (!employee) return;
const updatedCerts = [...bulkCertificates];
updatedCerts[certIndex] = {
...updatedCerts[certIndex],
matched_employee: employee,
match_confidence: 100,
status: 'matched',
manual_match: true
};
setBulkCertificates(updatedCerts);
toast({
title: "Employee Matched!",
description: `${employee.employee_name} assigned to ${updatedCerts[certIndex].file_name}`,
});
};
const processBulkFiles = async (files) => {
setBulkProcessing(true);
setProcessingProgress({ current: 0, total: files.length });
toast({
title: `Processing ${files.length} Certificate${files.length > 1 ? 's' : ''}`,
description: "AI is analyzing all certificates... This may take a minute.",
});
const processedCerts = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
setProcessingProgress({ current: i + 1, total: files.length });
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
const aiResult = await base44.integrations.Core.InvokeLLM({
prompt: `You are a food safety certification expert. Analyze this certificate and extract all information.
Extract:
1. Certificate holder name (full name) - BE VERY CAREFUL WITH THE NAME
2. Certificate number or ID
3. Issuing organization
4. Certificate type
5. Issue date
6. Expiration date
Validate:
- Is this legitimate?
- Is it expired?
- Confidence score (0-100)`,
add_context_from_internet: false,
file_urls: file_url,
response_json_schema: {
type: "object",
properties: {
certificate_holder_name: { type: "string" },
certificate_number: { type: "string" },
issuing_organization: { type: "string" },
certificate_type: { type: "string" },
issue_date: { type: "string" },
expiration_date: { type: "string" },
is_legitimate: { type: "boolean" },
is_expired: { type: "boolean" },
confidence_score: { type: "number" }
}
}
});
const matchResult = findEmployeeByName(aiResult.certificate_holder_name);
processedCerts.push({
file_name: file.name,
document_url: file_url,
ai_result: aiResult,
matched_employee: matchResult?.employee || null,
match_confidence: matchResult?.confidence || 0,
status: matchResult?.employee ? 'matched' : 'unmatched',
certification_name: aiResult.certificate_type || '',
certificate_number: aiResult.certificate_number || '',
issuer: aiResult.issuing_organization || '',
issue_date: aiResult.issue_date ? formatDateForInput(aiResult.issue_date) : '',
expiry_date: aiResult.expiration_date ? formatDateForInput(aiResult.expiration_date) : '',
confidence_score: aiResult.confidence_score || 0,
extracted_name: aiResult.certificate_holder_name || '',
manual_match: false
});
} catch (error) {
processedCerts.push({
file_name: file.name,
status: 'error',
error_message: error.message || 'Failed to process'
});
}
setBulkCertificates([...processedCerts]);
}
setBulkProcessing(false);
setProcessingProgress({ current: 0, total: 0 });
const matchedCount = processedCerts.filter(c => c.status === 'matched').length;
const unmatchedCount = processedCerts.filter(c => c.status === 'unmatched').length;
const errorCount = processedCerts.filter(c => c.status === 'error').length;
toast({
title: "Processing Complete!",
description: `${matchedCount} matched • ⚠️ ${unmatchedCount} need review ${errorCount > 0 ? `• ❌ ${errorCount} errors` : ''}`,
});
};
const handleBulkFileUpload = async (event) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
const invalidFiles = files.filter(f => !allowedTypes.includes(f.type));
if (invalidFiles.length > 0) {
toast({
title: "Invalid File Types",
description: `${invalidFiles.length} file(s) skipped. Only PDF and images allowed.`,
variant: "destructive",
});
}
const validFiles = files.filter(f => allowedTypes.includes(f.type) && f.size <= 10 * 1024 * 1024);
if (validFiles.length === 0) return;
await processBulkFiles(validFiles);
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files || []);
if (files.length === 0) return;
processBulkFiles(files);
};
const handleImportMatched = async () => {
const certsToImport = bulkCertificates.filter(c =>
c.status === 'matched' && c.matched_employee && c.certification_name && c.expiry_date
);
if (certsToImport.length === 0) {
toast({
title: "Nothing to Import",
description: "No matched certificates found",
variant: "destructive",
});
return;
}
const unmatchedCountBeforeImport = bulkCertificates.filter(c => c.status === 'unmatched').length;
toast({
title: `Importing ${certsToImport.length} Certificate${certsToImport.length > 1 ? 's' : ''}...`,
description: unmatchedCountBeforeImport > 0 ? `Skipping ${unmatchedCountBeforeImport} unmatched certificate${unmatchedCountBeforeImport > 1 ? 's' : ''}` : "Please wait...",
});
for (const cert of certsToImport) {
try {
const validationStatus = cert.confidence_score >= 80 ? "ai_verified" :
cert.confidence_score >= 60 ? "manual_review_needed" : "ai_flagged";
await createCertMutation.mutateAsync({
employee_id: cert.matched_employee.id,
employee_name: cert.matched_employee.employee_name,
certification_name: cert.certification_name,
certification_type: "Safety",
certificate_number: cert.certificate_number,
issuer: cert.issuer,
issue_date: cert.issue_date,
expiry_date: cert.expiry_date,
document_url: cert.document_url,
vendor_id: user?.id,
vendor_name: user?.company_name,
owner: user?.company_name,
expert_body: cert.issuer,
validation_status: validationStatus,
ai_validation_result: {
confidence_score: cert.confidence_score,
match_confidence: cert.match_confidence,
extracted_data: cert.ai_result,
manual_match: cert.manual_match || false,
flags: [],
recommendations: []
}
});
} catch (error) {
console.error('Failed to import cert:', error);
}
}
queryClient.invalidateQueries({ queryKey: ['certifications'] });
if (unmatchedCountBeforeImport > 0) {
const remainingCerts = bulkCertificates.filter(c => c.status === 'unmatched');
setBulkCertificates(remainingCerts);
toast({
title: "Import Complete!",
description: `Imported ${certsToImport.length} certificate${certsToImport.length > 1 ? 's' : ''}. ${unmatchedCountBeforeImport} still need matching.`,
});
} else {
setShowBulkImportDialog(false);
resetBulkImport();
toast({
title: "Import Complete!",
description: `Successfully imported all ${certsToImport.length} certificate${certsToImport.length > 1 ? 's' : ''}`,
});
}
};
const validateCertificateWithAI = async (fileUrl) => {
setValidating(true);
try {
const response = await base44.integrations.Core.InvokeLLM({
prompt: `You are a food safety certification expert. Analyze this uploaded certificate document and extract the following information.
Extract:
1. Certificate holder name
2. Certificate number or ID
3. Issuing organization
4. Certificate type
5. Issue date
6. Expiration date
7. Any special notes
Also validate:
- Is this legitimate?
- Is it expired?
- Confidence score (0-100)`,
add_context_from_internet: false,
file_urls: fileUrl,
response_json_schema: {
type: "object",
properties: {
certificate_holder_name: { type: "string" },
certificate_number: { type: "string" },
issuing_organization: { type: "string" },
certificate_type: { type: "string" },
issue_date: { type: "string" },
expiration_date: { type: "string" },
special_notes: { type: "string" },
is_legitimate: { type: "boolean" },
is_expired: { type: "boolean" },
red_flags: { type: "array", items: { type: "string" } },
recommendations: { type: "array", items: { type: "string" } },
confidence_score: { type: "number" }
}
}
});
setAiValidationResult(response);
if (response && response.certificate_type) {
setNewCert(prev => ({
...prev,
certification_name: response.certificate_type || prev.certification_name,
certificate_number: response.certificate_number || prev.certificate_number,
issuer: response.issuing_organization || prev.issuer,
issue_date: response.issue_date ? formatDateForInput(response.issue_date) : prev.issue_date,
expiry_date: response.expiration_date ? formatDateForInput(response.expiration_date) : prev.expiry_date,
expert_body: response.issuing_organization || prev.expert_body
}));
}
if (response && response.confidence_score >= 80 && response.is_legitimate) {
toast({
title: "✅ Certificate Verified!",
description: `AI validated with ${response.confidence_score}% confidence`,
});
} else if (response && response.confidence_score >= 60) {
toast({
title: "⚠️ Manual Review Recommended",
description: `AI confidence: ${response.confidence_score}%`,
});
} else {
toast({
title: "🚨 Validation Concerns",
description: `Low confidence (${response?.confidence_score || 0}%)`,
variant: "destructive",
});
}
return response;
} catch (error) {
toast({
title: "Validation Error",
description: error.message || "Failed to validate certificate",
variant: "destructive",
});
setAiValidationResult(null);
return null;
} finally {
setValidating(false);
}
};
const handleFileUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
if (!allowedTypes.includes(file.type)) {
toast({
title: "Invalid File Type",
description: "Please upload a PDF or image file",
variant: "destructive",
});
return;
}
if (file.size > 10 * 1024 * 1024) {
toast({
title: "File Too Large",
description: "Please upload a file smaller than 10MB",
variant: "destructive",
});
return;
}
setUploading(true);
setAiValidationResult(null);
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
setNewCert({ ...newCert, document_url: file_url });
setUploadedFileName(file.name);
toast({
title: "File Uploaded",
description: "Starting AI validation...",
});
await validateCertificateWithAI(file_url);
} catch (error) {
toast({
title: "Upload Failed",
description: error.message || "Failed to upload certificate",
variant: "destructive",
});
setNewCert({ ...newCert, document_url: "" });
setUploadedFileName("");
} finally {
setUploading(false);
}
};
const enrichedCerts = useMemo(() => {
const today = new Date();
return certifications.map(cert => {
const expiryDate = new Date(cert.expiry_date);
const daysUntilExpiry = differenceInDays(expiryDate, today);
let status = cert.status;
if (isBefore(expiryDate, today)) {
status = "expired";
} else if (daysUntilExpiry <= 30) {
status = "expiring_soon";
} else {
status = "current";
}
return {
...cert,
status,
days_until_expiry: daysUntilExpiry
};
});
}, [certifications]);
const currentCount = enrichedCerts.filter(c => c.status === "current").length;
const expiringSoonCount = enrichedCerts.filter(c => c.status === "expiring_soon").length;
const expiredCount = enrichedCerts.filter(c => c.status === "expired").length;
const complianceScore = certifications.length > 0
? Math.round((currentCount / certifications.length) * 100)
: 100;
const filteredCerts = enrichedCerts.filter(cert => {
const matchesSearch = !searchTerm ||
cert.employee_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
cert.certification_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
cert.issuer?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || cert.status === statusFilter;
const matchesType = typeFilter === "all" || cert.certification_type === typeFilter;
return matchesSearch && matchesStatus && matchesType;
});
const handleAddCertification = () => {
if (selectedEmployees.length === 0) {
toast({
title: "No Employees Selected",
description: "Please select at least one employee",
variant: "destructive",
});
return;
}
if (!newCert.certification_name || !newCert.expiry_date) {
toast({
title: "Missing Required Fields",
description: "Please fill in certification name and expiry date",
variant: "destructive",
});
return;
}
selectedEmployees.forEach(empId => {
const employee = staff.find(s => s.id === empId);
if (employee) {
const validationStatus = aiValidationResult
? (aiValidationResult.confidence_score >= 80 ? "ai_verified" :
aiValidationResult.confidence_score >= 60 ? "manual_review_needed" : "ai_flagged")
: "pending_expert_review";
createCertMutation.mutate({
...newCert,
employee_id: employee.id,
employee_name: employee.employee_name,
vendor_id: user?.id,
vendor_name: user?.company_name,
owner: user?.company_name,
validation_status: validationStatus,
ai_validation_result: aiValidationResult || null
});
}
});
setShowAddDialog(false);
resetForm();
toast({
title: "Certification Added",
description: "New certification has been added successfully",
});
};
const getStatusBadge = (status, daysUntilExpiry) => {
if (status === "current") {
return <Badge className="bg-green-100 text-green-700 font-semibold"> Current</Badge>;
}
if (status === "expiring_soon") {
return <Badge className="bg-yellow-100 text-yellow-700 font-semibold"> Expiring in {daysUntilExpiry}d</Badge>;
}
if (status === "expired") {
return <Badge className="bg-red-100 text-red-700 font-semibold"> Expired</Badge>;
}
return <Badge variant="outline">Unknown</Badge>;
};
const getValidationBadge = (cert) => {
if (!cert.validation_status) return null;
if (cert.validation_status === "ai_verified") {
return (
<Badge className="bg-blue-100 text-blue-700 border-blue-300">
<Sparkles className="w-3 h-3 mr-1" />
AI Verified
</Badge>
);
}
if (cert.validation_status === "approved") {
return <Badge className="bg-green-100 text-green-700"> Approved</Badge>;
}
if (cert.validation_status === "manual_review_needed") {
return <Badge className="bg-yellow-100 text-yellow-700"> Review Needed</Badge>;
}
if (cert.validation_status === "ai_flagged") {
return <Badge className="bg-red-100 text-red-700">🚨 Flagged</Badge>;
}
if (cert.validation_status === "pending_expert_review") {
return <Badge variant="outline" className="text-slate-600">Pending Review</Badge>;
}
return null;
};
const getStatusColor = (status) => {
if (status === "current") return "text-green-700";
if (status === "expiring_soon") return "text-yellow-700";
return "text-red-700";
};
const matchedCertsCount = bulkCertificates.filter(c => c.status === 'matched').length;
const unmatchedCertsCount = bulkCertificates.filter(c => c.status === 'unmatched').length;
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Compliance & Certification Control"
subtitle="Track, manage, and act on employee certifications with AI-powered validation"
actions={
<div className="flex gap-2">
<Button
onClick={() => setShowBulkImportDialog(true)}
variant="outline"
className="border-[#0A39DF] text-[#0A39DF] hover:bg-[#0A39DF]/10"
>
<FolderUp className="w-4 h-4 mr-2" />
Bulk Import
</Button>
<Button
onClick={() => setShowAddDialog(true)}
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg"
>
<Plus className="w-4 h-4 mr-2" />
Add Certification
</Button>
</div>
}
/>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="border-2 border-green-200 bg-gradient-to-br from-green-50 to-white shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-3">
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
<CheckCircle2 className="w-6 h-6 text-green-600" />
</div>
<Badge className="bg-green-500 text-white text-lg px-3 py-1">
{currentCount}
</Badge>
</div>
<p className="text-sm text-slate-600 font-medium">All Good</p>
<p className="text-2xl font-bold text-green-700">Current</p>
</CardContent>
</Card>
<Card className="border-2 border-yellow-200 bg-gradient-to-br from-yellow-50 to-white shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-3">
<div className="w-12 h-12 bg-yellow-100 rounded-xl flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-yellow-600" />
</div>
<Badge className="bg-yellow-500 text-white text-lg px-3 py-1">
{expiringSoonCount}
</Badge>
</div>
<p className="text-sm text-slate-600 font-medium">Almost Time to Renew</p>
<p className="text-2xl font-bold text-yellow-700">Expiring Soon</p>
</CardContent>
</Card>
<Card className="border-2 border-red-200 bg-gradient-to-br from-red-50 to-white shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-3">
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
<XCircle className="w-6 h-6 text-red-600" />
</div>
<Badge className="bg-red-500 text-white text-lg px-3 py-1">
{expiredCount}
</Badge>
</div>
<p className="text-sm text-slate-600 font-medium">Action Required Now</p>
<p className="text-2xl font-bold text-red-700">Expired</p>
</CardContent>
</Card>
<Card className="border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-white shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-3">
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
<Shield className="w-6 h-6 text-blue-600" />
</div>
<Badge className={`${
complianceScore >= 90 ? 'bg-green-500' :
complianceScore >= 70 ? 'bg-yellow-500' :
'bg-red-500'
} text-white text-lg px-3 py-1`}>
{complianceScore}%
</Badge>
</div>
<p className="text-sm text-slate-600 font-medium">Compliance Health</p>
<p className="text-2xl font-bold text-blue-700">Score</p>
</CardContent>
</Card>
</div>
{expiringSoonCount > 0 && (
<Card className="mb-8 border-2 border-yellow-200 bg-gradient-to-r from-yellow-50 to-amber-50 shadow-lg">
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-yellow-100 rounded-xl flex items-center justify-center flex-shrink-0">
<Zap className="w-6 h-6 text-yellow-600" />
</div>
<div className="flex-1">
<h3 className="font-bold text-lg text-slate-900 mb-2">🧠 KROW Whisper AI Insight</h3>
<p className="text-slate-700 mb-3">
<span className="font-semibold text-yellow-700">{expiringSoonCount} certifications</span> expire in the next 30 days.
Would you like to bulk-notify affected workers?
</p>
<div className="flex gap-3">
<Button size="sm" className="bg-yellow-600 hover:bg-yellow-700 text-white">
<Bell className="w-4 h-4 mr-2" />
Send Bulk Notification
</Button>
<Button size="sm" variant="outline">
<Download className="w-4 h-4 mr-2" />
Export Expiring List
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
<Card className="mb-6 border-slate-200 shadow-md">
<CardContent className="p-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search by employee, certification, or issuer..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full md:w-48">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="current"> Current</SelectItem>
<SelectItem value="expiring_soon"> Expiring Soon</SelectItem>
<SelectItem value="expired"> Expired</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-full md:w-48">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="Legal">Legal</SelectItem>
<SelectItem value="Operational">Operational</SelectItem>
<SelectItem value="Safety">Safety</SelectItem>
<SelectItem value="Training">Training</SelectItem>
<SelectItem value="License">License</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Award className="w-5 h-5 text-[#0A39DF]" />
Certification Registry
</CardTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['certifications'] })}>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{filteredCerts.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b-2 border-slate-200">
<tr>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Employee</th>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Certification</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Type</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Validation</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Status</th>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Expiry Date</th>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Issuer</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Action</th>
</tr>
</thead>
<tbody>
{filteredCerts.map((cert) => (
<tr
key={cert.id}
className={`border-b border-slate-100 hover:bg-slate-50 transition-colors ${
cert.status === 'expired' ? 'bg-red-50/50' :
cert.status === 'expiring_soon' ? 'bg-yellow-50/50' : ''
}`}
>
<td className="py-4 px-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-lg flex items-center justify-center text-white font-bold text-xs">
{cert.employee_name?.charAt(0) || '?'}
</div>
<span className="font-semibold text-slate-900">{cert.employee_name}</span>
</div>
</td>
<td className="py-4 px-4">
<div>
<p className="font-semibold text-slate-900">{cert.certification_name}</p>
{cert.certificate_number && (
<p className="text-xs text-slate-500">#{cert.certificate_number}</p>
)}
</div>
</td>
<td className="py-4 px-4 text-center">
<Badge variant="outline" className="font-medium">
{cert.certification_type}
</Badge>
</td>
<td className="py-4 px-4 text-center">
{getValidationBadge(cert)}
</td>
<td className="py-4 px-4 text-center">
{getStatusBadge(cert.status, cert.days_until_expiry)}
</td>
<td className="py-4 px-4">
<div className="flex items-center gap-2">
<Calendar className={`w-4 h-4 ${getStatusColor(cert.status)}`} />
<span className={`font-medium ${getStatusColor(cert.status)}`}>
{format(new Date(cert.expiry_date), 'MMM dd, yyyy')}
</span>
</div>
</td>
<td className="py-4 px-4 text-sm text-slate-700">
{cert.expert_body || cert.issuer || '—'}
</td>
<td className="py-4 px-4 text-center">
<div className="flex items-center justify-center gap-2">
{cert.document_url && (
<a href={cert.document_url} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="ghost" className="h-8">
<FileText className="w-4 h-4 mr-1" />
View
</Button>
</a>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-16 text-center">
<Shield className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-900 mb-2">No certifications found</h3>
<Button onClick={() => setShowAddDialog(true)} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-4 h-4 mr-2" />
Add First Certification
</Button>
</div>
)}
</CardContent>
</Card>
</div>
<Dialog open={showBulkImportDialog} onOpenChange={setShowBulkImportDialog}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl flex items-center gap-2">
<FolderUp className="w-6 h-6 text-[#0A39DF]" />
Bulk Certificate Import
</DialogTitle>
<DialogDescription>
Upload multiple certificates at once - AI will extract data and match to employees automatically
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{bulkCertificates.length === 0 && !bulkProcessing ? (
<div
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-12 text-center transition-all ${
isDragging
? 'border-[#0A39DF] bg-blue-50/50 scale-105'
: 'border-slate-300 hover:border-[#0A39DF]'
}`}
>
<FolderUp className={`w-16 h-16 mx-auto mb-4 transition-all ${
isDragging ? 'text-[#0A39DF] scale-110' : 'text-slate-400'
}`} />
<h3 className="text-lg font-semibold text-slate-900 mb-2">
{isDragging ? '📂 Drop Files Here!' : 'Drop Multiple Certificates Here'}
</h3>
<p className="text-sm text-slate-600 mb-4">
{isDragging
? 'Release to upload certificates'
: 'Upload PDF or image files • AI will auto-match to employees by name'}
</p>
{!isDragging && (
<>
<label htmlFor="bulk-upload">
<Button
type="button"
disabled={bulkProcessing}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
onClick={() => document.getElementById('bulk-upload').click()}
>
<Upload className="w-4 h-4 mr-2" />
Choose Files
</Button>
<input
id="bulk-upload"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={handleBulkFileUpload}
disabled={bulkProcessing}
/>
</label>
<p className="text-xs text-slate-500 mt-4">PDF, JPG, PNG Max 10MB each</p>
</>
)}
</div>
) : bulkProcessing ? (
<div className="text-center py-12">
<Loader2 className="w-12 h-12 mx-auto text-[#0A39DF] animate-spin mb-4" />
<h3 className="text-lg font-semibold text-slate-900 mb-2">Processing Certificates...</h3>
<p className="text-sm text-slate-600 mb-4">Please wait while AI analyzes each certificate</p>
{processingProgress.total > 0 && (
<div className="max-w-md mx-auto">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-700">
Progress: {processingProgress.current} / {processingProgress.total}
</span>
<span className="text-sm font-medium text-[#0A39DF]">
{Math.round((processingProgress.current / processingProgress.total) * 100)}%
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[#0A39DF] to-[#1C323E] transition-all duration-500"
style={{ width: `${(processingProgress.current / processingProgress.total) * 100}%` }}
/>
</div>
</div>
)}
{bulkCertificates.length > 0 && (
<div className="mt-6">
<p className="text-xs text-slate-500 mb-2">Preview (processing...)</p>
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg">
<table className="w-full text-sm">
<tbody>
{bulkCertificates.map((cert, idx) => (
<tr key={idx} className="border-b hover:bg-slate-50">
<td className="py-2 px-3 text-left">{cert.file_name}</td>
<td className="py-2 px-3 text-center">
{cert.status === 'matched' ? (
<CheckCircle2 className="w-4 h-4 text-green-600 mx-auto" />
) : cert.status === 'unmatched' ? (
<AlertTriangle className="w-4 h-4 text-yellow-600 mx-auto" />
) : (
<Loader2 className="w-4 h-4 text-slate-400 mx-auto animate-spin" />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
) : (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-lg">
Processed {bulkCertificates.length} Certificate{bulkCertificates.length !== 1 ? 's' : ''}
</h3>
<p className="text-sm text-slate-600 mt-1">
{matchedCertsCount} ready to import {unmatchedCertsCount} need employee selection
</p>
</div>
<Button variant="ghost" size="sm" onClick={resetBulkImport}>
<X className="w-4 h-4 mr-2" />
Clear All
</Button>
</div>
<div className="border border-slate-200 rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="text-left py-3 px-4 text-sm font-semibold text-slate-700">File</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-slate-700">Extracted Name</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-slate-700">Employee Match</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-slate-700">Certificate</th>
<th className="text-center py-3 px-4 text-sm font-semibold text-slate-700">Match %</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-slate-700">Expiry</th>
<th className="text-center py-3 px-4 text-sm font-semibold text-slate-700">Status</th>
</tr>
</thead>
<tbody>
{bulkCertificates.map((cert, idx) => (
<tr key={idx} className={`border-b hover:bg-slate-50 ${
cert.status === 'unmatched' ? 'bg-yellow-50/30' : ''
}`}>
<td className="py-3 px-4 text-sm font-medium">{cert.file_name}</td>
<td className="py-3 px-4 text-sm text-slate-600">{cert.extracted_name || '—'}</td>
<td className="py-3 px-4">
{cert.matched_employee ? (
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-600" />
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{cert.matched_employee.employee_name}</span>
{cert.manual_match && (
<Badge variant="outline" className="text-xs">Manual</Badge>
)}
</div>
{cert.matched_employee.position && (
<p className="text-xs text-slate-500">{cert.matched_employee.position}</p>
)}
</div>
</div>
) : cert.status === 'error' ? (
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
<span className="text-red-700 text-sm">Processing error</span>
</div>
) : (
<div className="w-full">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-yellow-600" />
<span className="text-yellow-700 text-sm font-medium">Select Employee:</span>
</div>
<Select onValueChange={(value) => handleManualEmployeeSelection(idx, value)}>
<SelectTrigger className="w-full border-yellow-300 bg-white">
<SelectValue placeholder="Choose employee..." />
</SelectTrigger>
<SelectContent>
{staff.map(employee => (
<SelectItem key={employee.id} value={employee.id}>
{employee.employee_name} {employee.position ? `(${employee.position})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</td>
<td className="py-3 px-4 text-sm">{cert.certification_name || '—'}</td>
<td className="py-3 px-4 text-center">
{cert.match_confidence ? (
<Badge className={`${
cert.match_confidence >= 90 ? 'bg-green-100 text-green-700' :
cert.match_confidence >= 75 ? 'bg-blue-100 text-blue-700' :
cert.match_confidence >= 60 ? 'bg-yellow-100 text-yellow-700' :
'bg-orange-100 text-orange-700'
}`}>
{cert.match_confidence}%
</Badge>
) : '—'}
</td>
<td className="py-3 px-4 text-sm">{cert.expiry_date || '—'}</td>
<td className="py-3 px-4 text-center">
{cert.status === 'matched' ? (
<Badge className="bg-green-100 text-green-700"> Ready</Badge>
) : cert.status === 'unmatched' ? (
<Badge className="bg-yellow-100 text-yellow-700"> Select Employee</Badge>
) : (
<Badge className="bg-red-100 text-red-700"> Error</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{unmatchedCertsCount > 0 && matchedCertsCount > 0 && (
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800 mb-2">
<strong>💡 Import Options:</strong> You have {matchedCertsCount} certificate{matchedCertsCount !== 1 ? 's' : ''} ready to import and {unmatchedCertsCount} that need employee selection.
</p>
<p className="text-xs text-blue-700">
Import matched now and manually assign unmatched later<br />
Or assign all employees first, then import everything together
</p>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowBulkImportDialog(false); resetBulkImport(); }}>
Cancel
</Button>
{bulkCertificates.length > 0 && !bulkProcessing && (
<>
{unmatchedCertsCount > 0 && matchedCertsCount > 0 && (
<Button
onClick={handleImportMatched}
variant="outline"
className="border-green-600 text-green-700 hover:bg-green-50"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Import {matchedCertsCount} Matched Only
</Button>
)}
<Button
onClick={handleImportMatched}
disabled={matchedCertsCount === 0}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
{unmatchedCertsCount === 0
? `Import All ${matchedCertsCount}`
: `Import ${matchedCertsCount} Ready`
} Certificate{matchedCertsCount !== 1 ? 's' : ''}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add New Certification</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Certification Name *</Label>
<Input
value={newCert.certification_name}
onChange={(e) => setNewCert({ ...newCert, certification_name: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label>Expiry Date *</Label>
<Input
type="date"
value={newCert.expiry_date}
onChange={(e) => setNewCert({ ...newCert, expiry_date: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label>Select Employees *</Label>
<div className="border rounded-md max-h-48 overflow-y-auto">
<table className="w-full text-sm">
<tbody>
{staff.map(employee => (
<tr key={employee.id} className="border-b hover:bg-slate-50">
<td className="py-2 px-4">
<input
type="checkbox"
checked={selectedEmployees.includes(employee.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedEmployees([...selectedEmployees, employee.id]);
} else {
setSelectedEmployees(selectedEmployees.filter(id => id !== employee.id));
}
}}
/>
</td>
<td className="py-2 px-4">{employee.employee_name}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddDialog(false)}>Cancel</Button>
<Button onClick={handleAddCertification}>Add Certification</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}