feat(candidates): scale UI, bulk upload & AI diffs

This commit is contained in:
Gabriel Ramos 2026-06-10 14:18:25 -04:00
parent aa028ec263
commit 1a439e3bd0
7 changed files with 1288 additions and 195 deletions

View file

@ -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. - **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. - **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. - **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
```

View file

@ -35,15 +35,85 @@ export async function POST(request: NextRequest) {
const embedding = await generateEmbedding(cleanText); const embedding = await generateEmbedding(cleanText);
const isTest = formData.get("isTest") === "true"; const isTest = formData.get("isTest") === "true";
const duplicateAction = formData.get("duplicateAction") || "check";
let candidateId = "00000000-0000-0000-0000-000000000000"; let candidateId = "00000000-0000-0000-0000-000000000000";
let candidateName = profile.candidateName; let candidateName = profile.candidateName;
let isDuplicate = false;
let existingCandidateData = null;
if (!isTest) { if (!isTest) {
// Initialize Supabase admin client // Initialize Supabase admin client
const supabase = createServerSupabaseClient(); const supabase = createServerSupabaseClient();
// Insert candidate with extracted details (including skills, summary, and cv_text) interface DbCandidate {
id: string;
name: string;
contact_info: {
email: string;
phone: string;
skills?: string[];
summary?: string;
};
}
// 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;
}
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 const { data: candidate, error: candidateError } = await supabase
.from("candidates") .from("candidates")
.insert({ .insert({
@ -69,10 +139,46 @@ export async function POST(request: NextRequest) {
candidateId = candidate.id; candidateId = candidate.id;
candidateName = candidate.name; candidateName = candidate.name;
// Decoupled: We no longer create an initial interview record on upload.
// Interviews are only queued when recruiter manually takes action.
} }
} 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({ return NextResponse.json({
success: true, success: true,

View file

@ -1,17 +1,34 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
interface Score { interface Score {
id: string; id: string;
candidate_id: string; candidate_id: string;
job_id?: string;
ai_score: number; ai_score: number;
evaluation: { evaluation: {
summary: string; summary: string | { en: string; es: string };
classification: string; classification: string;
suggestions: string; suggestions: string | { en: string; es: string };
riskLevel: 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; created_at: string;
} }
@ -25,6 +42,7 @@ interface Candidate {
summary?: string; summary?: string;
}; };
scores?: Score[]; scores?: Score[];
interviews?: Interview[];
created_at: string; created_at: string;
} }
@ -34,15 +52,177 @@ interface UploadFileStatus {
errorMessage?: string; 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<string, string>;
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() { export default function CandidatesPage() {
const [candidates, setCandidates] = useState<Candidate[]>([]); const [candidates, setCandidates] = useState<Candidate[]>([]);
const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Upload States
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [uploadStatuses, setUploadStatuses] = useState<UploadFileStatus[]>([]); const [uploadStatuses, setUploadStatuses] = useState<UploadFileStatus[]>([]);
const fetchCandidates = () => { // Search States
const [searchQuery, setSearchQuery] = useState("");
const [searchField, setSearchField] = useState<"name" | "email" | "skills">("name");
const searchInputRef = useRef<HTMLInputElement>(null);
// 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<DuplicateState | null>(null);
const fetchCandidates = useCallback((selectIdAfterFetch?: string) => {
fetch("/api/candidates") fetch("/api/candidates")
.then((res) => { .then((res) => {
if (!res.ok) throw new Error("Failed to fetch candidates"); if (!res.ok) throw new Error("Failed to fetch candidates");
@ -51,18 +231,28 @@ export default function CandidatesPage() {
.then((data) => { .then((data) => {
setCandidates(data); setCandidates(data);
setLoading(false); 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) => { .catch((err) => {
console.error(err); console.error(err);
setLoading(false); setLoading(false);
}); });
}; }, [selectedCandidate]);
useEffect(() => { useEffect(() => {
fetchCandidates(); fetchCandidates();
}, []); }, [fetchCandidates]);
// Upload PDF CVs in a vacuum
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@ -76,24 +266,29 @@ export default function CandidatesPage() {
})); }));
setUploadStatuses(initialStatuses); setUploadStatuses(initialStatuses);
setUploading(true); 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") { if (file.type !== "application/pdf") {
setUploadStatuses((prev) => setUploadStatuses((prev) =>
prev.map((status, idx) => prev.map((status, idx) =>
idx === index idx === i
? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" } ? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" }
: status : status
) )
); );
return; continue;
} }
let currentAction = "check";
let done = false;
while (!done) {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("duplicateAction", currentAction);
const res = await fetch("/candidates/api/parse-cv", { const res = await fetch("/candidates/api/parse-cv", {
method: "POST", method: "POST",
@ -105,54 +300,194 @@ export default function CandidatesPage() {
throw new Error(errData.error || "Failed to process CV"); throw new Error(errData.error || "Failed to process CV");
} }
await res.json(); 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) => setUploadStatuses((prev) =>
prev.map((status, idx) => prev.map((status, idx) =>
idx === index 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, status: "success" as const }
: status : status
) )
); );
done = true;
// Refresh with new candidate selected if we received it
if (data.candidateId) {
fetchCandidates(data.candidateId);
}
}
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Error uploading CV"; const msg = err instanceof Error ? err.message : "Error uploading CV";
setUploadStatuses((prev) => setUploadStatuses((prev) =>
prev.map((status, idx) => prev.map((status, idx) =>
idx === index idx === i
? { ...status, status: "error" as const, errorMessage: msg } ? { ...status, status: "error" as const, errorMessage: msg }
: status : status
) )
); );
done = true;
}
}
} }
});
await Promise.all(uploadPromises);
setUploading(false); setUploading(false);
fetchCandidates(); fetchCandidates();
e.target.value = ""; 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<string, LinkedVacancy>();
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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900">Candidates</h1>
<p className="text-slate-600 text-sm">
A list of all candidates parsed and analyzed by the AI recruitment pipeline.
</p>
</div>
</div>
{/* CV Uploader (Vacuum Ingestion) */} {/* CV Uploader (Vacuum Ingestion) */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col items-center justify-center text-center"> <div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col items-center justify-center text-center">
<h3 className="text-sm font-semibold text-slate-900 mb-1"> <h3 className="text-sm font-semibold text-slate-900 mb-1">
Ingest Candidate CV (in a Vacuum) {t.uploadCv}
</h3> </h3>
<p className="text-xs text-slate-500 mb-4 max-w-md"> <p className="text-xs text-slate-500 mb-4 max-w-md">
Upload a candidate CV PDF to parse contact info, skills, and summary. {t.uploadCvDesc}
No job position will be associated initially, keeping the data isolated.
</p> </p>
<label className="relative cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md text-sm transition duration-200"> <label className="relative cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md text-sm transition duration-200">
{uploading ? "Uploading CVs..." : "Upload CV Files"} {uploading ? t.uploadingButton : t.uploadButton}
<input <input
type="file" type="file"
accept=".pdf" accept=".pdf"
@ -162,20 +497,11 @@ export default function CandidatesPage() {
className="hidden" className="hidden"
/> />
</label> </label>
{uploadError && (
<p className="text-xs text-red-600 mt-3 font-semibold">
{uploadError}
</p>
)}
{uploadSuccess && (
<p className="text-xs text-green-600 mt-3 font-semibold">
{uploadSuccess}
</p>
)}
{uploadStatuses.length > 0 && ( {uploadStatuses.length > 0 && (
<div className="mt-4 w-full max-w-md border border-slate-200 rounded-md p-4 bg-slate-50 text-left"> <div className="mt-4 w-full max-w-md border border-slate-200 rounded-md p-4 bg-slate-50 text-left">
<h4 className="text-xs font-semibold text-slate-900 mb-2 uppercase tracking-wider"> <h4 className="text-xs font-semibold text-slate-900 mb-2 uppercase tracking-wider">
Upload Progress {t.uploadProgress}
</h4> </h4>
<ul className="divide-y divide-slate-200"> <ul className="divide-y divide-slate-200">
{uploadStatuses.map((item, idx) => ( {uploadStatuses.map((item, idx) => (
@ -186,23 +512,23 @@ export default function CandidatesPage() {
</span> </span>
{item.status === "uploading" && ( {item.status === "uploading" && (
<span className="text-slate-600 font-semibold flex items-center gap-1"> <span className="text-slate-600 font-semibold flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-pulse"></span> <span className="w-1.5 h-1.5 rounded-full bg-slate-500 animate-pulse"></span>
Uploading... {lang === "es" ? "Cargando..." : "Uploading..."}
</span> </span>
)} )}
{item.status === "success" && ( {item.status === "success" && (
<span className="text-green-600 font-semibold flex items-center gap-1"> <span className="text-green-700 font-semibold flex items-center gap-1">
Success {t.success}
</span> </span>
)} )}
{item.status === "error" && ( {item.status === "error" && (
<span className="text-red-600 font-semibold flex items-center gap-1"> <span className="text-red-700 font-semibold flex items-center gap-1">
Error {t.error}
</span> </span>
)} )}
</div> </div>
{item.errorMessage && ( {item.errorMessage && (
<p className="text-red-600 font-normal mt-0.5">{item.errorMessage}</p> <p className="text-red-700 font-normal mt-0.5">{item.errorMessage}</p>
)} )}
</li> </li>
))} ))}
@ -213,63 +539,166 @@ export default function CandidatesPage() {
{loading ? ( {loading ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200"> <div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
<p className="text-slate-500 text-sm">Loading candidates...</p> <p className="text-slate-500 text-sm">{t.loading}</p>
</div> </div>
) : candidates.length === 0 ? ( ) : candidates.length === 0 ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200"> <div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
<p className="text-slate-500 text-sm"> <p className="text-slate-500 text-sm">
No candidates found. Upload a CV above to get started. {t.noCandidatesFound}
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{candidates.map((candidate) => { {/* LEFT COLUMN: Sidebar (1/3 width) */}
return ( <div className="md:col-span-1 bg-white p-4 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-4">
<div <div className="flex items-center justify-between border-b border-slate-200 pb-3">
key={candidate.id}
className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-4"
>
{/* Candidate Info Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-200">
<div>
<h2 className="text-lg font-bold text-slate-900"> <h2 className="text-lg font-bold text-slate-900">
{candidate.name} {t.candidatesTitle}
</h2> </h2>
<div className="text-xs text-slate-500 mt-1"> <button
Email:{" "} onClick={() => setLang(lang === "en" ? "es" : "en")}
<span className="text-slate-600 font-medium mr-3"> className="px-2.5 py-1 text-xs font-semibold rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-600 transition"
>
{lang === "en" ? "ESPAÑOL" : "ENGLISH"}
</button>
</div>
{/* Search Input */}
<div className="flex flex-col gap-2">
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => 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 !== "" && (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-xs text-slate-500 font-medium">
{t.searchBy}:
</span>
<button
onClick={() => handleSearchFieldChange("name")}
className={`px-2 py-0.5 text-xs rounded transition font-medium border ${
searchField === "name"
? "bg-blue-600 text-white border-blue-600"
: "bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100"
}`}
>
{lang === "en" ? "Name" : "Nombre"}
</button>
<button
onClick={() => handleSearchFieldChange("email")}
className={`px-2 py-0.5 text-xs rounded transition font-medium border ${
searchField === "email"
? "bg-blue-600 text-white border-blue-600"
: "bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100"
}`}
>
{lang === "en" ? "Email" : "Correo"}
</button>
<button
onClick={() => handleSearchFieldChange("skills")}
className={`px-2 py-0.5 text-xs rounded transition font-medium border ${
searchField === "skills"
? "bg-blue-600 text-white border-blue-600"
: "bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100"
}`}
>
{lang === "en" ? "Skills" : "Habilidades"}
</button>
</div>
)}
</div>
{/* Alphabetical list of candidates */}
<div className="flex flex-col gap-1.5 max-h-[500px] overflow-y-auto pr-1">
{sortedCandidates.map((candidate) => (
<button
key={candidate.id}
onClick={() => setSelectedCandidate(candidate)}
className={`w-full text-left p-3 rounded-md border text-sm transition duration-200 ${
selectedCandidate?.id === candidate.id
? "border-blue-600 bg-slate-50 font-semibold"
: "border-slate-200 hover:border-slate-300 bg-white"
}`}
>
<div className="text-slate-900 font-medium truncate">
{candidate.name}
</div>
<div className="text-xs text-slate-500 truncate mt-0.5">
{candidate.contact_info.email} {candidate.contact_info.email}
</span> </div>
Phone:{" "} </button>
<span className="text-slate-600 font-medium"> ))}
{candidate.contact_info.phone} {sortedCandidates.length === 0 && (
</span> <p className="text-xs text-slate-500 italic p-3 text-center">
{lang === "es" ? "No se encontraron candidatos" : "No candidates found"}
</p>
)}
</div>
</div>
{/* RIGHT COLUMN: Detail Pane (2/3 width) */}
<div className="md:col-span-2">
{selectedCandidate ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-6">
{/* Header Profile Details */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 pb-4 border-b border-slate-200">
<div>
<h2 className="text-xl font-bold text-slate-900">
{selectedCandidate.name}
</h2>
<p className="text-xs text-slate-500 mt-1">
{t.createdDate}: {new Date(selectedCandidate.created_at).toLocaleDateString()}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 mt-3 text-xs">
<div>
<span className="text-slate-500">{t.email}: </span>
<span className="text-slate-600 font-medium">{selectedCandidate.contact_info.email}</span>
</div>
<div>
<span className="text-slate-500">{t.phone}: </span>
<span className="text-slate-600 font-medium">{selectedCandidate.contact_info.phone || "N/A"}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Extracted Profile (Summary & Skills) */} <button
<div className="flex flex-col gap-2"> onClick={() => setShowDeleteConfirm(true)}
{candidate.contact_info.summary && ( className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs font-semibold rounded-md transition"
>
{t.deleteCandidate}
</button>
</div>
{/* Summary Extracted */}
{selectedCandidate.contact_info.summary && (
<div> <div>
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-1"> <h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-2">
Professional Summary (Extracted) {t.professionalSummary}
</span> </h3>
<p className="text-slate-600 text-sm leading-relaxed"> <p className="text-slate-600 text-sm leading-relaxed whitespace-pre-wrap">
{candidate.contact_info.summary} {selectedCandidate.contact_info.summary}
</p> </p>
</div> </div>
)} )}
{candidate.contact_info.skills && candidate.contact_info.skills.length > 0 && (
<div className="mt-1"> {/* Skills tag group */}
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-1"> {selectedCandidate.contact_info.skills && selectedCandidate.contact_info.skills.length > 0 && (
Skills & Technologies <div>
</span> <h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-2">
{t.skillsAndTech}
</h3>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{candidate.contact_info.skills.map((skill) => ( {selectedCandidate.contact_info.skills.map((skill) => (
<span <span
key={skill} key={skill}
className="px-2 py-0.5 bg-slate-50 text-slate-600 text-xs rounded border border-slate-200" className="px-2 py-0.5 bg-slate-50 text-slate-600 text-xs rounded border border-slate-200 font-medium"
> >
{skill} {skill}
</span> </span>
@ -277,10 +706,203 @@ export default function CandidatesPage() {
</div> </div>
</div> </div>
)} )}
{/* Linked Vacancies Section */}
<div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-3">
{t.linkedVacancies}
</h3>
{linkedVacancies.length > 0 ? (
<div className="border border-slate-200 rounded-md overflow-hidden bg-white">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 text-slate-500 text-xs border-b border-slate-200 font-semibold">
<th className="p-3 font-semibold">{t.jobTitle}</th>
<th className="p-3 font-semibold">{t.aiScore}</th>
<th className="p-3 font-semibold">{t.classification}</th>
<th className="p-3 font-semibold">{t.stage}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 text-sm">
{linkedVacancies.map((vacancy) => (
<tr key={vacancy.jobId} className="text-slate-600">
<td className="p-3 font-medium text-slate-900">
{vacancy.jobTitle}
</td>
<td className="p-3 font-semibold text-blue-600">
{vacancy.aiScore !== null ? `${vacancy.aiScore} / 100` : "-"}
</td>
<td className="p-3">
{vacancy.classification ? (
<span className={`px-2 py-0.5 text-xs rounded-md font-semibold border ${
vacancy.classification === "Qualified"
? "bg-green-50 text-green-700 border-green-200"
: vacancy.classification === "Review"
? "bg-slate-50 text-slate-600 border-slate-200"
: "bg-red-50 text-red-700 border-red-200"
}`}>
{translateClassification(vacancy.classification, lang)}
</span>
) : (
"-"
)}
</td>
<td className="p-3">
{vacancy.stage ? (
<span className="px-2 py-0.5 text-xs rounded-md bg-blue-50 text-blue-700 border border-blue-200 font-semibold">
{translateStage(vacancy.stage, lang)}
</span>
) : (
"-"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-xs text-slate-500 italic">
{t.noLinkedVacancies}
</p>
)}
</div>
</div>
) : (
<div className="bg-white p-12 rounded-lg shadow-sm border border-slate-200 flex flex-col items-center justify-center text-center">
<p className="text-slate-600 font-semibold mb-2">
{t.noCandidateSelected}
</p>
</div>
)}
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-md border border-slate-200 max-w-md w-full p-6">
<h3 className="text-lg font-bold text-slate-900 mb-2">
{t.confirmTitle}
</h3>
<p className="text-sm text-slate-600 mb-6">
{t.deleteConfirmation}
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 border border-slate-200 rounded-md text-sm text-slate-600 bg-white hover:bg-slate-50 transition"
>
{t.cancel}
</button>
<button
onClick={handleDeleteCandidate}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm font-semibold transition"
>
{t.confirmDelete}
</button>
</div>
</div>
</div>
)}
{/* Duplicate Detection dialog */}
{duplicateData && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-md border border-slate-200 max-w-xl w-full p-6 flex flex-col gap-4 max-h-[90vh] overflow-y-auto">
<div>
<h3 className="text-lg font-bold text-slate-900">
{t.duplicateDetected}
</h3>
<p className="text-xs text-slate-500 mt-1">
{t.duplicateMsg} ({duplicateData.fileName})
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Existing Profile */}
<div className="border border-slate-200 rounded-md p-3 bg-slate-50 text-xs">
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
{t.existingProfile}
</h4>
<div className="text-sm font-bold text-slate-900">
{duplicateData.existingCandidate.name}
</div>
<div className="text-xs text-slate-500 mt-1">
Email: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.email}</span>
</div>
<div className="text-xs text-slate-500">
Phone: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span>
</div>
{duplicateData.existingCandidate.contact_info.summary && (
<p className="text-slate-600 mt-2 line-clamp-3">
{duplicateData.existingCandidate.contact_info.summary}
</p>
)}
</div>
{/* New Profile */}
<div className="border border-slate-200 rounded-md p-3 bg-slate-50 text-xs">
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
{t.newProfile}
</h4>
<div className="text-sm font-bold text-slate-900">
{duplicateData.newProfile.candidateName || duplicateData.newProfile.name || "Unknown"}
</div>
<div className="text-xs text-slate-500 mt-1">
Email: <span className="text-slate-600 font-medium">{duplicateData.newProfile.email || "N/A"}</span>
</div>
<div className="text-xs text-slate-500">
Phone: <span className="text-slate-600 font-medium">{duplicateData.newProfile.phone || "N/A"}</span>
</div>
{duplicateData.newProfile.summary && (
<p className="text-slate-600 mt-2 line-clamp-3">
{duplicateData.newProfile.summary}
</p>
)}
</div>
</div>
{/* AI Comparison Summary */}
<div className="border border-slate-200 rounded-md p-3 bg-blue-50">
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-2">
{t.aiComparison}
</h4>
{duplicateData.comparison ? (
<p className="text-xs text-slate-600 leading-relaxed">
{getBilingualText(duplicateData.comparison, lang)}
</p>
) : (
<p className="text-xs text-slate-500 italic">
{lang === "es" ? "Comparando perfiles con IA..." : "Comparing profiles with AI..."}
</p>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-slate-200">
<button
onClick={() => duplicateData.onResolve("cancel")}
className="px-3 py-1.5 border border-slate-200 rounded-md text-xs text-slate-600 bg-white hover:bg-slate-50 transition"
>
{lang === "es" ? "Cancelar" : "Cancel"}
</button>
<button
onClick={() => duplicateData.onResolve("ignore")}
className="px-3 py-1.5 bg-slate-600 hover:bg-slate-700 text-white rounded-md text-xs font-semibold transition"
>
{lang === "es" ? "Conservar ambos" : "Keep Both"}
</button>
<button
onClick={() => duplicateData.onResolve("overwrite")}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-xs font-semibold transition"
>
{lang === "es" ? "Sobrescribir" : "Overwrite"}
</button>
</div> </div>
</div> </div>
);
})}
</div> </div>
)} )}
</div> </div>

View file

@ -75,6 +75,33 @@ interface UploadFileStatus {
errorMessage?: string; 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() { export default function JobsPage() {
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [selectedJob, setSelectedJob] = useState<Job | null>(null); const [selectedJob, setSelectedJob] = useState<Job | null>(null);
@ -86,6 +113,7 @@ export default function JobsPage() {
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null); const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [uploadStatuses, setUploadStatuses] = useState<UploadFileStatus[]>([]); const [uploadStatuses, setUploadStatuses] = useState<UploadFileStatus[]>([]);
const [duplicateData, setDuplicateData] = useState<DuplicateState | null>(null);
// Evaluation states // Evaluation states
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({}); const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
@ -222,21 +250,28 @@ export default function JobsPage() {
setUploadError(null); setUploadError(null);
setUploadSuccess(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") { if (file.type !== "application/pdf") {
setUploadStatuses((prev) => setUploadStatuses((prev) =>
prev.map((status, idx) => prev.map((status, idx) =>
idx === index idx === i
? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" } ? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" }
: status : status
) )
); );
return; continue;
} }
let currentAction = "check";
let done = false;
while (!done) {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("duplicateAction", currentAction);
formData.append("jobId", selectedJob.id); formData.append("jobId", selectedJob.id);
const res = await fetch("/candidates/api/parse-cv", { const res = await fetch("/candidates/api/parse-cv", {
@ -249,27 +284,80 @@ export default function JobsPage() {
throw new Error(errData.error || "Failed to process CV"); throw new Error(errData.error || "Failed to process CV");
} }
await res.json(); 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) => setUploadStatuses((prev) =>
prev.map((status, idx) => prev.map((status, idx) =>
idx === index 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, status: "success" as const }
: status : status
) )
); );
done = true;
}
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Error uploading CV"; const msg = err instanceof Error ? err.message : "Error uploading CV";
setUploadStatuses((prev) => setUploadStatuses((prev) =>
prev.map((status, idx) => prev.map((status, idx) =>
idx === index idx === i
? { ...status, status: "error" as const, errorMessage: msg } ? { ...status, status: "error" as const, errorMessage: msg }
: status : status
) )
); );
done = true;
}
}
} }
});
await Promise.all(uploadPromises);
setUploading(false); setUploading(false);
// Refresh matches for current job // Refresh matches for current job
@ -901,6 +989,105 @@ export default function JobsPage() {
</div> </div>
)} )}
</div> </div>
{/* Duplicate Detection dialog */}
{duplicateData && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-md border border-slate-200 max-w-xl w-full p-6 flex flex-col gap-4 max-h-[90vh] overflow-y-auto">
<div>
<h3 className="text-lg font-bold text-slate-900">
Duplicate Candidate Detected / Candidato Duplicado Detectado
</h3>
<p className="text-xs text-slate-500 mt-1">
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})
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
{/* Existing Profile */}
<div className="border border-slate-200 rounded-md p-3 bg-slate-50">
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
Existing Profile / Perfil Existente
</h4>
<div className="text-sm font-bold text-slate-900">
{duplicateData.existingCandidate.name}
</div>
<div className="text-xs text-slate-500 mt-1">
Email: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.email}</span>
</div>
<div className="text-xs text-slate-500">
Phone: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span>
</div>
{duplicateData.existingCandidate.contact_info.summary && (
<p className="text-slate-600 mt-2 line-clamp-3">
{duplicateData.existingCandidate.contact_info.summary}
</p>
)}
</div>
{/* New Profile */}
<div className="border border-slate-200 rounded-md p-3 bg-slate-50">
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
Newly Uploaded Profile / Nuevo Perfil Cargado
</h4>
<div className="text-sm font-bold text-slate-900">
{duplicateData.newProfile.candidateName || duplicateData.newProfile.name || "Unknown"}
</div>
<div className="text-xs text-slate-500 mt-1">
Email: <span className="text-slate-600 font-medium">{duplicateData.newProfile.email || "N/A"}</span>
</div>
<div className="text-xs text-slate-500">
Phone: <span className="text-slate-600 font-medium">{duplicateData.newProfile.phone || "N/A"}</span>
</div>
{duplicateData.newProfile.summary && (
<p className="text-slate-600 mt-2 line-clamp-3">
{duplicateData.newProfile.summary}
</p>
)}
</div>
</div>
{/* AI Comparison Summary */}
<div className="border border-slate-200 rounded-md p-3 bg-blue-50">
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-2">
AI Comparison Summary / Resumen de Comparación de IA
</h4>
{duplicateData.comparison ? (
<div className="text-xs text-slate-600 leading-relaxed flex flex-col gap-2">
<p><strong>EN:</strong> {duplicateData.comparison.en}</p>
<p><strong>ES:</strong> {duplicateData.comparison.es}</p>
</div>
) : (
<p className="text-xs text-slate-500 italic">
Comparing profiles with AI... / Comparando perfiles con IA...
</p>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-slate-200">
<button
onClick={() => duplicateData.onResolve("cancel")}
className="px-3 py-1.5 border border-slate-200 rounded-md text-xs text-slate-600 bg-white hover:bg-slate-50 transition"
>
Cancel / Cancelar
</button>
<button
onClick={() => duplicateData.onResolve("ignore")}
className="px-3 py-1.5 bg-slate-600 hover:bg-slate-700 text-white rounded-md text-xs font-semibold transition"
>
Keep Both / Conservar ambos
</button>
<button
onClick={() => duplicateData.onResolve("overwrite")}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-xs font-semibold transition"
>
Overwrite / Sobrescribir
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View file

@ -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 });
}
}

View file

@ -12,6 +12,10 @@ interface CandidateScore {
riskLevel: string; riskLevel: string;
}; };
created_at: string; created_at: string;
job_id?: string;
jobs?: {
title: string;
};
} }
interface InterviewDetail { interface InterviewDetail {
@ -19,6 +23,10 @@ interface InterviewDetail {
stage: string; stage: string;
interview_date: string; interview_date: string;
feedback: string | null; feedback: string | null;
created_at?: string;
jobs?: {
title: string;
};
} }
interface RankedCandidate { interface RankedCandidate {
@ -35,6 +43,20 @@ interface RankedCandidate {
interview?: InterviewDetail | null; 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) { export async function GET(request: NextRequest) {
try { try {
const supabase = createServerSupabaseClient(); const supabase = createServerSupabaseClient();
@ -144,20 +166,33 @@ export async function GET(request: NextRequest) {
return NextResponse.json(candidatesList); return NextResponse.json(candidatesList);
} else { } 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 const { data: candidates, error } = await supabase
.from("candidates") .from("candidates")
.select("*, scores(*)") .select(`
.order("created_at", { ascending: false }) *,
.order("created_at", { referencedTable: "scores", ascending: false }); scores (
*,
jobs (
title
)
),
interviews (
*,
jobs (
title
)
)
`)
.order("created_at", { ascending: false });
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ error: error.message }, { status: 500 });
} }
// Safeguard: Sort and normalize scores inside each candidate in Javascript as well // Safeguard: Sort and normalize scores inside each candidate in Javascript as well
const typedCandidates = candidates || []; const typedCandidates = (candidates as unknown as DBCandidate[]) || [];
typedCandidates.forEach(cand => { typedCandidates.forEach((cand: DBCandidate) => {
if (cand.scores && Array.isArray(cand.scores)) { if (cand.scores && Array.isArray(cand.scores)) {
cand.scores.forEach((s: CandidateScore) => { cand.scores.forEach((s: CandidateScore) => {
if (s.ai_score <= 1.0) { 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()); 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); return NextResponse.json(typedCandidates);
@ -188,3 +230,29 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: errorMessage }, { status: 500 }); 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 });
}
}

View file

@ -13,13 +13,30 @@ const config: Config = {
white: "#ffffff", white: "#ffffff",
slate: { slate: {
50: "#f8fafc", 50: "#f8fafc",
200: "#e2e8f0",
500: "#64748b",
600: "#475569", 600: "#475569",
900: "#0f172a", 900: "#0f172a",
}, },
blue: { blue: {
50: "#eff6ff",
200: "#bfdbfe",
600: "#2563eb", 600: "#2563eb",
700: "#1d4ed8", 700: "#1d4ed8",
}, },
red: {
50: "#fef2f2",
200: "#fecaca",
600: "#dc2626",
700: "#b91c1c",
},
green: {
50: "#f0fdf4",
100: "#dcfce7",
200: "#bbf7d0",
700: "#15803d",
800: "#166534",
},
}, },
}, },
plugins: [], plugins: [],