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.
1325 lines
55 KiB
JavaScript
1325 lines
55 KiB
JavaScript
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>
|
||
);
|
||
} |