feat(candidates): scale UI, bulk upload & AI diffs
This commit is contained in:
parent
aa028ec263
commit
1a439e3bd0
7 changed files with 1288 additions and 195 deletions
12
README.md
12
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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -35,15 +35,85 @@ 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)
|
||||
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
|
||||
.from("candidates")
|
||||
.insert({
|
||||
|
|
@ -69,10 +139,46 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
candidateId = candidate.id;
|
||||
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({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
const [candidates, setCandidates] = useState<Candidate[]>([]);
|
||||
const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Upload States
|
||||
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 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")
|
||||
.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<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
|
@ -76,24 +266,29 @@ 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;
|
||||
}
|
||||
|
||||
let currentAction = "check";
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("duplicateAction", currentAction);
|
||||
|
||||
const res = await fetch("/candidates/api/parse-cv", {
|
||||
method: "POST",
|
||||
|
|
@ -105,54 +300,194 @@ export default function CandidatesPage() {
|
|||
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) =>
|
||||
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
|
||||
)
|
||||
);
|
||||
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 === index
|
||||
idx === i
|
||||
? { ...status, status: "error" as const, errorMessage: msg }
|
||||
: status
|
||||
)
|
||||
);
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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<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 (
|
||||
<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) */}
|
||||
<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">
|
||||
Ingest Candidate CV (in a Vacuum)
|
||||
{t.uploadCv}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mb-4 max-w-md">
|
||||
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}
|
||||
</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">
|
||||
{uploading ? "Uploading CVs..." : "Upload CV Files"}
|
||||
{uploading ? t.uploadingButton : t.uploadButton}
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
|
|
@ -162,20 +497,11 @@ export default function CandidatesPage() {
|
|||
className="hidden"
|
||||
/>
|
||||
</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 && (
|
||||
<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">
|
||||
Upload Progress
|
||||
{t.uploadProgress}
|
||||
</h4>
|
||||
<ul className="divide-y divide-slate-200">
|
||||
{uploadStatuses.map((item, idx) => (
|
||||
|
|
@ -186,23 +512,23 @@ export default function CandidatesPage() {
|
|||
</span>
|
||||
{item.status === "uploading" && (
|
||||
<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>
|
||||
Uploading...
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-500 animate-pulse"></span>
|
||||
{lang === "es" ? "Cargando..." : "Uploading..."}
|
||||
</span>
|
||||
)}
|
||||
{item.status === "success" && (
|
||||
<span className="text-green-600 font-semibold flex items-center gap-1">
|
||||
✓ Success
|
||||
<span className="text-green-700 font-semibold flex items-center gap-1">
|
||||
✓ {t.success}
|
||||
</span>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<span className="text-red-600 font-semibold flex items-center gap-1">
|
||||
✗ Error
|
||||
<span className="text-red-700 font-semibold flex items-center gap-1">
|
||||
✗ {t.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
|
|
@ -213,63 +539,166 @@ export default function CandidatesPage() {
|
|||
|
||||
{loading ? (
|
||||
<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>
|
||||
) : candidates.length === 0 ? (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<p className="text-slate-500 text-sm">
|
||||
No candidates found. Upload a CV above to get started.
|
||||
{t.noCandidatesFound}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{candidates.map((candidate) => {
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* LEFT COLUMN: Sidebar (1/3 width) */}
|
||||
<div className="md:col-span-1 bg-white p-4 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
|
||||
<h2 className="text-lg font-bold text-slate-900">
|
||||
{candidate.name}
|
||||
{t.candidatesTitle}
|
||||
</h2>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Email:{" "}
|
||||
<span className="text-slate-600 font-medium mr-3">
|
||||
<button
|
||||
onClick={() => setLang(lang === "en" ? "es" : "en")}
|
||||
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}
|
||||
</span>
|
||||
Phone:{" "}
|
||||
<span className="text-slate-600 font-medium">
|
||||
{candidate.contact_info.phone}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{sortedCandidates.length === 0 && (
|
||||
<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>
|
||||
|
||||
{/* Extracted Profile (Summary & Skills) */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{candidate.contact_info.summary && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
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>
|
||||
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-1">
|
||||
Professional Summary (Extracted)
|
||||
</span>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">
|
||||
{candidate.contact_info.summary}
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-2">
|
||||
{t.professionalSummary}
|
||||
</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{selectedCandidate.contact_info.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{candidate.contact_info.skills && candidate.contact_info.skills.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-1">
|
||||
Skills & Technologies
|
||||
</span>
|
||||
|
||||
{/* Skills tag group */}
|
||||
{selectedCandidate.contact_info.skills && selectedCandidate.contact_info.skills.length > 0 && (
|
||||
<div>
|
||||
<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">
|
||||
{candidate.contact_info.skills.map((skill) => (
|
||||
{selectedCandidate.contact_info.skills.map((skill) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
|
|
@ -277,10 +706,203 @@ export default function CandidatesPage() {
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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<Job[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||
|
|
@ -86,6 +113,7 @@ export default function JobsPage() {
|
|||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
||||
const [uploadStatuses, setUploadStatuses] = useState<UploadFileStatus[]>([]);
|
||||
const [duplicateData, setDuplicateData] = useState<DuplicateState | null>(null);
|
||||
|
||||
// Evaluation states
|
||||
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
|
||||
|
|
@ -222,21 +250,28 @@ 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;
|
||||
}
|
||||
|
||||
let currentAction = "check";
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("duplicateAction", currentAction);
|
||||
formData.append("jobId", selectedJob.id);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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) =>
|
||||
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
|
||||
)
|
||||
);
|
||||
done = true;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Error uploading CV";
|
||||
setUploadStatuses((prev) =>
|
||||
prev.map((status, idx) =>
|
||||
idx === index
|
||||
idx === i
|
||||
? { ...status, status: "error" as const, errorMessage: msg }
|
||||
: status
|
||||
)
|
||||
);
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
setUploading(false);
|
||||
|
||||
// Refresh matches for current job
|
||||
|
|
@ -901,6 +989,105 @@ export default function JobsPage() {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
81
app/api/candidates/compare/route.ts
Normal file
81
app/api/candidates/compare/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue