From 1a439e3bd0749b16a6594428654a0ea65b55c98c Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Wed, 10 Jun 2026 14:18:25 -0400 Subject: [PATCH] feat(candidates): scale UI, bulk upload & AI diffs --- README.md | 12 + .../candidates/api/parse-cv/route.ts | 158 +++- app/(dashboard)/candidates/page.tsx | 880 +++++++++++++++--- app/(dashboard)/jobs/page.tsx | 255 ++++- app/api/candidates/compare/route.ts | 81 ++ app/api/candidates/route.ts | 80 +- tailwind.config.ts | 17 + 7 files changed, 1288 insertions(+), 195 deletions(-) create mode 100644 app/api/candidates/compare/route.ts diff --git a/README.md b/README.md index b832b50..3fef61a 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,15 @@ A modern ATS platform designed to parse PDF CVs using multimodal AI, rank candid - **Recruiter - Stage Automation:** As a recruiter, I want candidates to move through recruitment stages automatically. - **Candidate - Automated Emails:** As a candidate, I want to receive automated email confirmations for every stage change. - **Talent Team - Vacancy Metrics:** As a talent team, we want metrics on the progress per vacancy. + +## Setup & Prerequisites + +### 1. Google AI Studio Account (Mandatory) +Vector embeddings matching and search operations require a direct call to the Google Gemini Embeddings API (`models/gemini-embedding-001`). +* **Prerequisite**: You must obtain a free-tier or paid-tier Gemini API key from [Google AI Studio](https://aistudio.google.com/). +* **Usage**: The embedding model is free for up to 1,500 requests per day (15 requests per minute), which covers standard development and testing requirements. +* **Configuration**: Add your key to the `.env` file at the root of the project: + ```env + GEMINI_API_KEY=your_google_ai_studio_api_key_here + ``` + diff --git a/app/(dashboard)/candidates/api/parse-cv/route.ts b/app/(dashboard)/candidates/api/parse-cv/route.ts index 1d41da1..a339d29 100644 --- a/app/(dashboard)/candidates/api/parse-cv/route.ts +++ b/app/(dashboard)/candidates/api/parse-cv/route.ts @@ -35,45 +35,151 @@ export async function POST(request: NextRequest) { const embedding = await generateEmbedding(cleanText); const isTest = formData.get("isTest") === "true"; + const duplicateAction = formData.get("duplicateAction") || "check"; let candidateId = "00000000-0000-0000-0000-000000000000"; let candidateName = profile.candidateName; + let isDuplicate = false; + let existingCandidateData = null; if (!isTest) { // Initialize Supabase admin client const supabase = createServerSupabaseClient(); - // Insert candidate with extracted details (including skills, summary, and cv_text) - const { data: candidate, error: candidateError } = await supabase - .from("candidates") - .insert({ - name: profile.candidateName, - contact_info: { - email: profile.email, - phone: profile.phone, - skills: profile.skills || [], - summary: profile.summary || "", - cv_text: cleanText, - }, - embedding, - }) - .select("*") - .single(); - - if (candidateError || !candidate) { - return NextResponse.json( - { error: candidateError?.message || "Failed to insert candidate" }, - { status: 500 } - ); + interface DbCandidate { + id: string; + name: string; + contact_info: { + email: string; + phone: string; + skills?: string[]; + summary?: string; + }; } - candidateId = candidate.id; - candidateName = candidate.name; + // Check for duplicate candidate (by email or exact name match) + let existingCandidate: DbCandidate | null = null; + if (profile.email) { + const { data } = await supabase + .from("candidates") + .select("*") + .eq("contact_info->>email", profile.email) + .maybeSingle(); + existingCandidate = data as DbCandidate | null; + } - // Decoupled: We no longer create an initial interview record on upload. - // Interviews are only queued when recruiter manually takes action. + if (!existingCandidate && profile.candidateName) { + const { data } = await supabase + .from("candidates") + .select("*") + .ilike("name", profile.candidateName) + .maybeSingle(); + existingCandidate = data as DbCandidate | null; + } + + if (existingCandidate) { + if (duplicateAction === "check") { + isDuplicate = true; + existingCandidateData = { + id: existingCandidate.id, + name: existingCandidate.name, + contact_info: existingCandidate.contact_info, + }; + } else if (duplicateAction === "overwrite") { + const { data: updated, error: updateError } = await supabase + .from("candidates") + .update({ + name: profile.candidateName, + contact_info: { + email: profile.email, + phone: profile.phone, + skills: profile.skills || [], + summary: profile.summary || "", + cv_text: cleanText, + }, + embedding, + }) + .eq("id", existingCandidate.id) + .select("*") + .single(); + + if (updateError || !updated) { + return NextResponse.json( + { error: updateError?.message || "Failed to overwrite candidate" }, + { status: 500 } + ); + } + + candidateId = updated.id; + candidateName = updated.name; + } else { + // ignore: insert as new candidate + const { data: candidate, error: candidateError } = await supabase + .from("candidates") + .insert({ + name: profile.candidateName, + contact_info: { + email: profile.email, + phone: profile.phone, + skills: profile.skills || [], + summary: profile.summary || "", + cv_text: cleanText, + }, + embedding, + }) + .select("*") + .single(); + + if (candidateError || !candidate) { + return NextResponse.json( + { error: candidateError?.message || "Failed to insert candidate" }, + { status: 500 } + ); + } + + candidateId = candidate.id; + candidateName = candidate.name; + } + } else { + // No duplicate found, insert new candidate + const { data: candidate, error: candidateError } = await supabase + .from("candidates") + .insert({ + name: profile.candidateName, + contact_info: { + email: profile.email, + phone: profile.phone, + skills: profile.skills || [], + summary: profile.summary || "", + cv_text: cleanText, + }, + embedding, + }) + .select("*") + .single(); + + if (candidateError || !candidate) { + return NextResponse.json( + { error: candidateError?.message || "Failed to insert candidate" }, + { status: 500 } + ); + } + + candidateId = candidate.id; + candidateName = candidate.name; + } } + if (isDuplicate) { + return NextResponse.json({ + success: true, + isDuplicate: true, + existingCandidate: existingCandidateData, + newProfile: profile, + }); + } + + return NextResponse.json({ success: true, candidateId, diff --git a/app/(dashboard)/candidates/page.tsx b/app/(dashboard)/candidates/page.tsx index b2e082f..ff25da4 100644 --- a/app/(dashboard)/candidates/page.tsx +++ b/app/(dashboard)/candidates/page.tsx @@ -1,17 +1,34 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; interface Score { id: string; candidate_id: string; + job_id?: string; ai_score: number; evaluation: { - summary: string; + summary: string | { en: string; es: string }; classification: string; - suggestions: 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; } @@ -25,6 +42,7 @@ interface Candidate { summary?: string; }; scores?: Score[]; + interviews?: Interview[]; created_at: string; } @@ -34,15 +52,177 @@ interface UploadFileStatus { 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 [uploadError, setUploadError] = useState(null); - const [uploadSuccess, setUploadSuccess] = useState(null); const [uploadStatuses, setUploadStatuses] = useState([]); + + // Search States + const [searchQuery, setSearchQuery] = useState(""); + const [searchField, setSearchField] = useState<"name" | "email" | "skills">("name"); + const searchInputRef = useRef(null); - const fetchCandidates = () => { + // i18n Language Toggle State + const [lang, setLang] = useState<"en" | "es">("en"); + 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"); @@ -51,18 +231,28 @@ export default function CandidatesPage() { .then((data) => { setCandidates(data); setLoading(false); + if (selectIdAfterFetch) { + const matched = data.find((c: Candidate) => c.id === selectIdAfterFetch); + if (matched) setSelectedCandidate(matched); + } else if (data.length > 0 && !selectedCandidate) { + // Default selection if none is currently selected + setSelectedCandidate(data[0]); + } else if (selectedCandidate) { + // Keep current selection fresh + const refreshed = data.find((c: Candidate) => c.id === selectedCandidate.id); + setSelectedCandidate(refreshed || data[0] || null); + } }) .catch((err) => { console.error(err); setLoading(false); }); - }; + }, [selectedCandidate]); useEffect(() => { fetchCandidates(); - }, []); + }, [fetchCandidates]); - // Upload PDF CVs in a vacuum const handleFileUpload = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; @@ -76,83 +266,228 @@ export default function CandidatesPage() { })); setUploadStatuses(initialStatuses); setUploading(true); - setUploadError(null); - setUploadSuccess(null); - const uploadPromises = fileList.map(async (file, index) => { + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + if (file.type !== "application/pdf") { setUploadStatuses((prev) => prev.map((status, idx) => - idx === index + idx === i ? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" } : status ) ); - return; + continue; } - try { - const formData = new FormData(); - formData.append("file", file); + let currentAction = "check"; + let done = false; - const res = await fetch("/candidates/api/parse-cv", { - method: "POST", - body: formData, - }); + while (!done) { + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("duplicateAction", currentAction); - if (!res.ok) { - const errData = await res.json(); - throw new Error(errData.error || "Failed to process CV"); + 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; } - - await res.json(); - setUploadStatuses((prev) => - prev.map((status, idx) => - idx === index - ? { ...status, status: "success" as const } - : status - ) - ); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "Error uploading CV"; - setUploadStatuses((prev) => - prev.map((status, idx) => - idx === index - ? { ...status, status: "error" as const, errorMessage: msg } - : status - ) - ); } - }); + } - await Promise.all(uploadPromises); 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 (
-
-
-

Candidates

-

- A list of all candidates parsed and analyzed by the AI recruitment pipeline. -

-
-
- {/* CV Uploader (Vacuum Ingestion) */}

- Ingest Candidate CV (in a Vacuum) + {t.uploadCv}

- Upload a candidate CV PDF to parse contact info, skills, and summary. - No job position will be associated initially, keeping the data isolated. + {t.uploadCvDesc}

- {uploadError && ( -

- {uploadError} -

- )} - {uploadSuccess && ( -

- {uploadSuccess} -

- )} + {uploadStatuses.length > 0 && (

- Upload Progress + {t.uploadProgress}

    {uploadStatuses.map((item, idx) => ( @@ -186,23 +512,23 @@ export default function CandidatesPage() { {item.status === "uploading" && ( - - Uploading... + + {lang === "es" ? "Cargando..." : "Uploading..."} )} {item.status === "success" && ( - - ✓ Success + + ✓ {t.success} )} {item.status === "error" && ( - - ✗ Error + + ✗ {t.error} )}
{item.errorMessage && ( -

{item.errorMessage}

+

{item.errorMessage}

)} ))} @@ -213,74 +539,370 @@ export default function CandidatesPage() { {loading ? (
-

Loading candidates...

+

{t.loading}

) : candidates.length === 0 ? (

- No candidates found. Upload a CV above to get started. + {t.noCandidatesFound}

) : ( -
- {candidates.map((candidate) => { - return ( -
+ {/* LEFT COLUMN: Sidebar (1/3 width) */} +
+
+

+ {t.candidatesTitle} +

+ +
- {/* Extracted Profile (Summary & Skills) */} -
- {candidate.contact_info.summary && ( -
- - Professional Summary (Extracted) - -

- {candidate.contact_info.summary} -

-
- )} - {candidate.contact_info.skills && candidate.contact_info.skills.length > 0 && ( -
- - Skills & Technologies - -
- {candidate.contact_info.skills.map((skill) => ( - - {skill} - - ))} + {/* Search Input */} +
+ setSearchQuery(e.target.value)} + placeholder={t.searchPlaceholder} + className="w-full px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-white placeholder:text-slate-500 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 */} +
+ + + +
+
)}
diff --git a/app/(dashboard)/jobs/page.tsx b/app/(dashboard)/jobs/page.tsx index d9ff83d..e3eac00 100644 --- a/app/(dashboard)/jobs/page.tsx +++ b/app/(dashboard)/jobs/page.tsx @@ -75,6 +75,33 @@ interface UploadFileStatus { errorMessage?: string; } +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; +} + export default function JobsPage() { const [jobs, setJobs] = useState([]); const [selectedJob, setSelectedJob] = useState(null); @@ -86,6 +113,7 @@ export default function JobsPage() { const [uploadError, setUploadError] = useState(null); const [uploadSuccess, setUploadSuccess] = useState(null); const [uploadStatuses, setUploadStatuses] = useState([]); + const [duplicateData, setDuplicateData] = useState(null); // Evaluation states const [evaluatingIds, setEvaluatingIds] = useState>({}); @@ -222,54 +250,114 @@ export default function JobsPage() { setUploadError(null); setUploadSuccess(null); - const uploadPromises = fileList.map(async (file, index) => { + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + if (file.type !== "application/pdf") { setUploadStatuses((prev) => prev.map((status, idx) => - idx === index + idx === i ? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" } : status ) ); - return; + continue; } - try { - const formData = new FormData(); - formData.append("file", file); - formData.append("jobId", selectedJob.id); + let currentAction = "check"; + let done = false; - const res = await fetch("/candidates/api/parse-cv", { - method: "POST", - body: formData, - }); + while (!done) { + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("duplicateAction", currentAction); + formData.append("jobId", selectedJob.id); - if (!res.ok) { - const errData = await res.json(); - throw new Error(errData.error || "Failed to process CV"); + 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; + } + } 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; } - - await res.json(); - setUploadStatuses((prev) => - prev.map((status, idx) => - idx === index - ? { ...status, status: "success" as const } - : status - ) - ); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "Error uploading CV"; - setUploadStatuses((prev) => - prev.map((status, idx) => - idx === index - ? { ...status, status: "error" as const, errorMessage: msg } - : status - ) - ); } - }); + } - await Promise.all(uploadPromises); setUploading(false); // Refresh matches for current job @@ -901,6 +989,105 @@ export default function JobsPage() {
)}
+ + {/* Duplicate Detection dialog */} + {duplicateData && ( +
+
+
+

+ Duplicate Candidate Detected / Candidato Duplicado Detectado +

+

+ The system detected an existing candidate with the same email or name. / El sistema detectó un candidato existente con el mismo correo o nombre. ({duplicateData.fileName}) +

+
+ +
+ {/* Existing Profile */} +
+

+ Existing Profile / Perfil Existente +

+
+ {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 */} +
+

+ Newly Uploaded Profile / Nuevo Perfil Cargado +

+
+ {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 */} +
+

+ AI Comparison Summary / Resumen de Comparación de IA +

+ {duplicateData.comparison ? ( +
+

EN: {duplicateData.comparison.en}

+

ES: {duplicateData.comparison.es}

+
+ ) : ( +

+ Comparing profiles with AI... / Comparando perfiles con IA... +

+ )} +
+ + {/* Actions */} +
+ + + +
+
+
+ )}
); } diff --git a/app/api/candidates/compare/route.ts b/app/api/candidates/compare/route.ts new file mode 100644 index 0000000..8acde22 --- /dev/null +++ b/app/api/candidates/compare/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + try { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + return NextResponse.json({ error: "Missing GEMINI_API_KEY environment variable" }, { status: 500 }); + } + + const body = await request.json(); + const { existingProfile, newProfile } = body; + + if (!existingProfile || !newProfile) { + return NextResponse.json({ error: "existingProfile and newProfile are required" }, { status: 400 }); + } + + const prompt = `You are an AI recruitment assistant. Compare the existing candidate profile against the newly uploaded CV profile for a candidate. +Analyze differences in skills, experience, and summary. +Determine: +1. If they appear to be the same person (updated resume) or two different people with the same name. +2. What new skills or experiences are present in the new profile compared to the old one. + +Existing Profile: +Name: ${existingProfile.name} +Skills: ${JSON.stringify(existingProfile.skills || existingProfile.contact_info?.skills || [])} +Summary: ${existingProfile.summary || existingProfile.contact_info?.summary || ""} + +New Profile: +Name: ${newProfile.candidateName || newProfile.name} +Skills: ${JSON.stringify(newProfile.skills || newProfile.contact_info?.skills || [])} +Summary: ${newProfile.summary || newProfile.contact_info?.summary || ""} + +You MUST respond with a raw JSON object containing exactly these two keys: +- en: A concise 2-3 sentence summary of the differences in English. +- es: A concise 2-3 sentence summary of the differences in Spanish. + +Do not include any markdown formatting, code blocks, or text outside the JSON.`; + + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + contents: [{ + parts: [{ text: prompt }] + }], + generationConfig: { + responseMimeType: "application/json", + } + }), + } + ); + + if (!response.ok) { + const errText = await response.text(); + return NextResponse.json({ error: `Gemini API error: ${response.status} - ${errText}` }, { status: 500 }); + } + + const data = await response.json(); + const textContent = data.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!textContent) { + return NextResponse.json({ error: "Failed to generate comparison from Gemini" }, { status: 500 }); + } + + const parsedComparison = JSON.parse(textContent.trim()); + + return NextResponse.json({ + success: true, + comparison: parsedComparison, + }); + + } catch (error: unknown) { + console.error("Error in compare API:", error); + const errorMessage = error instanceof Error ? error.message : "Internal Server Error"; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/app/api/candidates/route.ts b/app/api/candidates/route.ts index 25e02ba..fa527c6 100644 --- a/app/api/candidates/route.ts +++ b/app/api/candidates/route.ts @@ -12,6 +12,10 @@ interface CandidateScore { riskLevel: string; }; created_at: string; + job_id?: string; + jobs?: { + title: string; + }; } interface InterviewDetail { @@ -19,6 +23,10 @@ interface InterviewDetail { stage: string; interview_date: string; feedback: string | null; + created_at?: string; + jobs?: { + title: string; + }; } interface RankedCandidate { @@ -35,6 +43,20 @@ interface RankedCandidate { interview?: InterviewDetail | null; } +interface DBCandidate { + id: string; + name: string; + contact_info: { + email: string; + phone: string; + skills?: string[]; + summary?: string; + }; + created_at: string; + scores?: CandidateScore[]; + interviews?: InterviewDetail[]; +} + export async function GET(request: NextRequest) { try { const supabase = createServerSupabaseClient(); @@ -144,20 +166,33 @@ export async function GET(request: NextRequest) { return NextResponse.json(candidatesList); } else { - // Fetch all candidates sorted by created_at descending, along with scores ordered descending + // Fetch all candidates sorted by created_at descending, along with scores and interviews, including job titles const { data: candidates, error } = await supabase .from("candidates") - .select("*, scores(*)") - .order("created_at", { ascending: false }) - .order("created_at", { referencedTable: "scores", ascending: false }); + .select(` + *, + scores ( + *, + jobs ( + title + ) + ), + interviews ( + *, + jobs ( + title + ) + ) + `) + .order("created_at", { ascending: false }); if (error) { return NextResponse.json({ error: error.message }, { status: 500 }); } // Safeguard: Sort and normalize scores inside each candidate in Javascript as well - const typedCandidates = candidates || []; - typedCandidates.forEach(cand => { + const typedCandidates = (candidates as unknown as DBCandidate[]) || []; + typedCandidates.forEach((cand: DBCandidate) => { if (cand.scores && Array.isArray(cand.scores)) { cand.scores.forEach((s: CandidateScore) => { if (s.ai_score <= 1.0) { @@ -179,6 +214,13 @@ export async function GET(request: NextRequest) { }); cand.scores.sort((a: CandidateScore, b: CandidateScore) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); } + if (cand.interviews && Array.isArray(cand.interviews)) { + cand.interviews.sort((a: InterviewDetail, b: InterviewDetail) => { + const dateA = a.created_at ? new Date(a.created_at).getTime() : 0; + const dateB = b.created_at ? new Date(b.created_at).getTime() : 0; + return dateB - dateA; + }); + } }); return NextResponse.json(typedCandidates); @@ -188,3 +230,29 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: errorMessage }, { status: 500 }); } } + +export async function DELETE(request: NextRequest) { + try { + const supabase = createServerSupabaseClient(); + const id = request.nextUrl.searchParams.get("id"); + + if (!id) { + return NextResponse.json({ error: "Candidate ID is required" }, { status: 400 }); + } + + const { error } = await supabase + .from("candidates") + .delete() + .eq("id", id); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ success: true, message: "Candidate deleted successfully" }); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Internal Server Error"; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} + diff --git a/tailwind.config.ts b/tailwind.config.ts index 19cdb13..c2fcee6 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -13,13 +13,30 @@ const config: Config = { white: "#ffffff", slate: { 50: "#f8fafc", + 200: "#e2e8f0", + 500: "#64748b", 600: "#475569", 900: "#0f172a", }, blue: { + 50: "#eff6ff", + 200: "#bfdbfe", 600: "#2563eb", 700: "#1d4ed8", }, + red: { + 50: "#fef2f2", + 200: "#fecaca", + 600: "#dc2626", + 700: "#b91c1c", + }, + green: { + 50: "#f0fdf4", + 100: "#dcfce7", + 200: "#bbf7d0", + 700: "#15803d", + 800: "#166534", + }, }, }, plugins: [],