"use client"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { useApp } from "@/components/AppContext"; interface Score { id: string; candidate_id: string; job_id?: string; ai_score: number; evaluation: { summary: string | { en: string; es: string }; classification: string; suggestions: string | { en: string; es: string }; riskLevel: string; }; jobs?: { title: string; }; created_at: string; } interface Interview { id: string; candidate_id: string; job_id: string; stage: string; interview_date: string; feedback: string | null; jobs?: { title: string; }; created_at: string; } interface Candidate { id: string; name: string; contact_info: { email: string; phone: string; skills?: string[]; summary?: string; }; scores?: Score[]; interviews?: Interview[]; created_at: string; } interface UploadFileStatus { name: string; status: "uploading" | "success" | "error"; errorMessage?: string; } interface LinkedVacancy { jobId: string; jobTitle: string; aiScore: number | null; classification: string | null; stage: string | null; } interface DuplicateState { fileName: string; existingCandidate: { id: string; name: string; contact_info: { email: string; phone: string; skills?: string[]; summary?: string; }; }; newProfile: { candidateName?: string; name?: string; email?: string; phone?: string; skills?: string[]; summary?: string; }; comparison: { en: string; es: string; } | null; onResolve: (action: "overwrite" | "ignore" | "cancel") => void; } const translations = { en: { candidatesTitle: "Candidates", candidatesSubtitle: "A list of all candidates parsed and analyzed by the AI recruitment pipeline.", searchPlaceholder: "Search candidates...", searchBy: "Search by", name: "Name", email: "Email", skills: "Skills", deleteCandidate: "Delete Candidate", deleteConfirmation: "Are you sure you want to delete this candidate? This action cannot be undone.", noCandidateSelected: "Select a candidate to view details", uploadCv: "Ingest Candidate CV (in a Vacuum)", uploadCvDesc: "Upload a candidate CV PDF to parse contact info, skills, and summary. No job position will be associated initially, keeping the data isolated.", uploadButton: "Upload CV Files", uploadingButton: "Uploading CVs...", uploadProgress: "Upload Progress", success: "Success", error: "Error", phone: "Phone", createdDate: "Added on", professionalSummary: "Professional Summary (Extracted)", skillsAndTech: "Skills & Technologies", linkedVacancies: "Linked Vacancies", jobTitle: "Job Title", aiScore: "AI Suitability Score", classification: "Classification", stage: "Stage", noLinkedVacancies: "No vacancies linked to this candidate yet.", cancel: "Cancel", confirmDelete: "Yes, Delete", confirmTitle: "Confirm Deletion", duplicateDetected: "Duplicate Candidate Detected", duplicateMsg: "The system detected an existing candidate with the same email or name.", existingProfile: "Existing Profile", newProfile: "Newly Uploaded Profile", aiComparison: "AI Comparison Summary", overwrite: "Overwrite", keepBoth: "Keep Both", loading: "Loading candidates...", noCandidatesFound: "No candidates found. Upload a CV above to get started.", }, es: { candidatesTitle: "Candidatos", candidatesSubtitle: "Lista de todos los candidatos analizados por el pipeline de reclutamiento de IA.", searchPlaceholder: "Buscar candidatos...", searchBy: "Buscar por", name: "Nombre", email: "Correo", skills: "Habilidades", deleteCandidate: "Eliminar Candidato", deleteConfirmation: "¿Está seguro de que desea eliminar a este candidato? Esta acción no se puede deshacer.", noCandidateSelected: "Seleccione un candidato para ver los detalles", uploadCv: "Ingresar CV de Candidato (Aislado)", uploadCvDesc: "Cargue un archivo PDF de CV de candidato para extraer información de contacto, habilidades y resumen. No se asociará ninguna vacante inicialmente.", uploadButton: "Cargar Archivos de CV", uploadingButton: "Cargando CVs...", uploadProgress: "Progreso de Carga", success: "Éxito", error: "Error", phone: "Teléfono", createdDate: "Añadido el", professionalSummary: "Resumen Profesional (Extraído)", skillsAndTech: "Habilidades y Tecnologías", linkedVacancies: "Vacantes Vinculadas", jobTitle: "Título del Puesto", aiScore: "Puntaje de Idoneidad de IA", classification: "Clasificación", stage: "Etapa", noLinkedVacancies: "Aún no hay vacantes vinculadas a este candidato.", cancel: "Cancelar", confirmDelete: "Sí, Eliminar", confirmTitle: "Confirmar Eliminación", duplicateDetected: "Candidato Duplicado Detectado", duplicateMsg: "El sistema detectó un candidato existente con el mismo correo o nombre.", existingProfile: "Perfil Existente", newProfile: "Nuevo Perfil Cargado", aiComparison: "Resumen de Comparación de IA", overwrite: "Sobrescribir", keepBoth: "Conservar ambos", loading: "Cargando candidatos...", noCandidatesFound: "No se encontraron candidatos. Cargue un CV arriba para comenzar.", } }; const getBilingualText = (field: unknown, lang: "en" | "es") => { if (!field) return ""; if (typeof field === "object" && field !== null) { const record = field as Record; return record[lang] || record.en || record.es || ""; } return String(field); }; const translateClassification = (cls: string, lang: "en" | "es") => { if (lang === "es") { if (cls === "Qualified") return "Calificado"; if (cls === "Review") return "En Revisión"; if (cls === "Unqualified") return "No Calificado"; } return cls; }; const translateStage = (stage: string, lang: "en" | "es") => { if (lang === "es") { if (stage === "Screening") return "Preselección"; if (stage === "Technical") return "Técnica"; if (stage === "Cultural") return "Cultural"; if (stage === "Offer") return "Oferta"; } return stage; }; export default function CandidatesPage() { const [candidates, setCandidates] = useState([]); const [selectedCandidate, setSelectedCandidate] = useState(null); const [loading, setLoading] = useState(true); // Upload States const [uploading, setUploading] = useState(false); const [uploadStatuses, setUploadStatuses] = useState([]); // Search States const [searchQuery, setSearchQuery] = useState(""); const [searchField, setSearchField] = useState<"name" | "email" | "skills">("name"); const searchInputRef = useRef(null); // i18n Language Toggle State const { lang } = useApp(); const t = translations[lang]; // Delete & Duplicate States const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [duplicateData, setDuplicateData] = useState(null); const fetchCandidates = useCallback((selectIdAfterFetch?: string) => { fetch("/api/candidates") .then((res) => { if (!res.ok) throw new Error("Failed to fetch candidates"); return res.json(); }) .then((data) => { setCandidates(data); setLoading(false); if (selectIdAfterFetch) { const matched = data.find((c: Candidate) => c.id === selectIdAfterFetch); if (matched) setSelectedCandidate(matched); } else { // Keep current selection fresh using functional update setSelectedCandidate((prevSelected) => { if (!prevSelected) return data[0] || null; const refreshed = data.find((c: Candidate) => c.id === prevSelected.id); return refreshed || data[0] || null; }); } }) .catch((err) => { console.error(err); setLoading(false); }); }, []); useEffect(() => { fetchCandidates(); }, [fetchCandidates]); const handleFileUpload = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; const fileList = Array.from(files); // Set initial status const initialStatuses = fileList.map((file) => ({ name: file.name, status: "uploading" as const, })); setUploadStatuses(initialStatuses); setUploading(true); for (let i = 0; i < fileList.length; i++) { const file = fileList[i]; if (file.type !== "application/pdf") { setUploadStatuses((prev) => prev.map((status, idx) => idx === i ? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" } : status ) ); continue; } let currentAction = "check"; let done = false; while (!done) { try { const formData = new FormData(); formData.append("file", file); formData.append("duplicateAction", currentAction); const res = await fetch("/candidates/api/parse-cv", { method: "POST", body: formData, }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || "Failed to process CV"); } const data = await res.json(); if (data.isDuplicate) { // Trigger AI Comparison summary let comparisonResult = null; try { const compRes = await fetch("/api/candidates/compare", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ existingProfile: data.existingCandidate, newProfile: data.newProfile, }), }); if (compRes.ok) { const compData = await compRes.json(); comparisonResult = compData.comparison; } } catch (compErr) { console.error("Comparison request failed:", compErr); } // Pause and wait for user's decision const userAction = await new Promise<"overwrite" | "ignore" | "cancel">((resolve) => { setDuplicateData({ fileName: file.name, existingCandidate: data.existingCandidate, newProfile: data.newProfile, comparison: comparisonResult, onResolve: resolve, }); }); // Close dialog setDuplicateData(null); if (userAction === "cancel") { setUploadStatuses((prev) => prev.map((status, idx) => idx === i ? { ...status, status: "error" as const, errorMessage: "Upload cancelled by user" } : status ) ); done = true; } else { // Resend request with overwrite or ignore parameter currentAction = userAction === "overwrite" ? "overwrite" : "ignore"; } } else { // Success setUploadStatuses((prev) => prev.map((status, idx) => idx === i ? { ...status, status: "success" as const } : status ) ); done = true; // Refresh with new candidate selected if we received it if (data.candidateId) { fetchCandidates(data.candidateId); } } } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Error uploading CV"; setUploadStatuses((prev) => prev.map((status, idx) => idx === i ? { ...status, status: "error" as const, errorMessage: msg } : status ) ); done = true; } } } setUploading(false); fetchCandidates(); e.target.value = ""; }; const handleDeleteCandidate = async () => { if (!selectedCandidate) return; try { const res = await fetch(`/api/candidates?id=${selectedCandidate.id}`, { method: "DELETE", }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || "Failed to delete candidate"); } setShowDeleteConfirm(false); // Remove from list and reset selected candidate const updatedCandidates = candidates.filter((c) => c.id !== selectedCandidate.id); setCandidates(updatedCandidates); setSelectedCandidate(updatedCandidates[0] || null); } catch (err: unknown) { alert(err instanceof Error ? err.message : "Error deleting candidate"); } }; const handleSearchFieldChange = (field: "name" | "email" | "skills") => { setSearchField(field); if (searchInputRef.current) { searchInputRef.current.focus(); } }; // 1. Search filter const filteredCandidates = candidates.filter((candidate) => { if (!searchQuery.trim()) return true; const q = searchQuery.toLowerCase().trim(); if (searchField === "email") { return candidate.contact_info?.email?.toLowerCase().includes(q) ?? false; } if (searchField === "skills") { return candidate.contact_info?.skills?.some(skill => skill.toLowerCase().includes(q)) ?? false; } // Default to Name return candidate.name.toLowerCase().includes(q); }); // 2. Alphabetical A-Z sort by candidate name const sortedCandidates = [...filteredCandidates].sort((a, b) => a.name.localeCompare(b.name) ); // Group linked vacancies (scores + interviews) const getLinkedVacancies = (cand: Candidate): LinkedVacancy[] => { const vacancyMap = new Map(); if (cand.scores && Array.isArray(cand.scores)) { cand.scores.forEach((score) => { const jobId = score.job_id; if (!jobId) return; vacancyMap.set(jobId, { jobId, jobTitle: score.jobs?.title || "Unknown Position", aiScore: score.ai_score, classification: score.evaluation?.classification || null, stage: null, }); }); } if (cand.interviews && Array.isArray(cand.interviews)) { cand.interviews.forEach((interview) => { const jobId = interview.job_id; if (!jobId) return; const existing = vacancyMap.get(jobId); if (existing) { existing.stage = interview.stage || null; } else { vacancyMap.set(jobId, { jobId, jobTitle: interview.jobs?.title || "Unknown Position", aiScore: null, classification: null, stage: interview.stage || null, }); } }); } return Array.from(vacancyMap.values()); }; const linkedVacancies = selectedCandidate ? getLinkedVacancies(selectedCandidate) : []; return (
{/* CV Uploader (Vacuum Ingestion) */}

{t.uploadCv}

{t.uploadCvDesc}

{uploadStatuses.length > 0 && (

{t.uploadProgress}

    {uploadStatuses.map((item, idx) => (
  • {item.name} {item.status === "uploading" && ( {lang === "es" ? "Cargando..." : "Uploading..."} )} {item.status === "success" && ( ✓ {t.success} )} {item.status === "error" && ( ✗ {t.error} )}
    {item.errorMessage && (

    {item.errorMessage}

    )}
  • ))}
)}
{loading ? (

{t.loading}

) : candidates.length === 0 ? (

{t.noCandidatesFound}

) : (
{/* LEFT COLUMN: Sidebar (1/3 width) */}

{t.candidatesTitle}

{/* Search Input */}
setSearchQuery(e.target.value)} placeholder={t.searchPlaceholder} className="w-full px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-white bg-white dark:bg-slate-800 placeholder:text-slate-500 dark:placeholder:text-slate-400 text-sm focus:outline-none" /> {/* Search Quick Actions (only show when searchQuery !== "") */} {searchQuery !== "" && (
{t.searchBy}:
)}
{/* Alphabetical list of candidates */}
{sortedCandidates.map((candidate) => ( ))} {sortedCandidates.length === 0 && (

{lang === "es" ? "No se encontraron candidatos" : "No candidates found"}

)}
{/* RIGHT COLUMN: Detail Pane (2/3 width) */}
{selectedCandidate ? (
{/* Header Profile Details */}

{selectedCandidate.name}

{t.createdDate}: {new Date(selectedCandidate.created_at).toLocaleDateString()}

{t.email}: {selectedCandidate.contact_info.email}
{t.phone}: {selectedCandidate.contact_info.phone || "N/A"}
{/* Summary Extracted */} {selectedCandidate.contact_info.summary && (

{t.professionalSummary}

{selectedCandidate.contact_info.summary}

)} {/* Skills tag group */} {selectedCandidate.contact_info.skills && selectedCandidate.contact_info.skills.length > 0 && (

{t.skillsAndTech}

{selectedCandidate.contact_info.skills.map((skill) => ( {skill} ))}
)} {/* Linked Vacancies Section */}

{t.linkedVacancies}

{linkedVacancies.length > 0 ? (
{linkedVacancies.map((vacancy) => ( ))}
{t.jobTitle} {t.aiScore} {t.classification} {t.stage}
{vacancy.jobTitle} {vacancy.aiScore !== null ? `${vacancy.aiScore} / 100` : "-"} {vacancy.classification ? ( {translateClassification(vacancy.classification, lang)} ) : ( "-" )} {vacancy.stage ? ( {translateStage(vacancy.stage, lang)} ) : ( "-" )}
) : (

{t.noLinkedVacancies}

)}
) : (

{t.noCandidateSelected}

)}
)} {/* Delete Confirmation Modal */} {showDeleteConfirm && (

{t.confirmTitle}

{t.deleteConfirmation}

)} {/* Duplicate Detection dialog */} {duplicateData && (

{t.duplicateDetected}

{t.duplicateMsg} ({duplicateData.fileName})

{/* Existing Profile */}

{t.existingProfile}

{duplicateData.existingCandidate.name}
Email: {duplicateData.existingCandidate.contact_info.email}
Phone: {duplicateData.existingCandidate.contact_info.phone || "N/A"}
{duplicateData.existingCandidate.contact_info.summary && (

{duplicateData.existingCandidate.contact_info.summary}

)}
{/* New Profile */}

{t.newProfile}

{duplicateData.newProfile.candidateName || duplicateData.newProfile.name || "Unknown"}
Email: {duplicateData.newProfile.email || "N/A"}
Phone: {duplicateData.newProfile.phone || "N/A"}
{duplicateData.newProfile.summary && (

{duplicateData.newProfile.summary}

)}
{/* AI Comparison Summary */}

{t.aiComparison}

{duplicateData.comparison ? (

{getBilingualText(duplicateData.comparison, lang)}

) : (

{lang === "es" ? "Comparando perfiles con IA..." : "Comparing profiles with AI..."}

)}
{/* Actions */}
)}
); }