"use client"; import React, { useState, useEffect } from "react"; interface Job { id: string; title: string; requirements: { text: string; skills?: string[]; summary?: string; }; created_at: string; } interface Score { id: string; candidate_id: string; ai_score: number; evaluation: { summary: string; classification: string; suggestions: string; riskLevel: string; }; } interface Candidate { id: string; name: string; contact_info: { email: string; phone: string; skills?: string[]; summary?: string; }; similarity?: number; scores?: Score[]; interview?: { id: string; stage: string; interview_date: string; feedback: string | null; } | null; created_at: string; } interface SkillsOverlap { matchedSkills: string[]; missingSkills: string[]; overlapCount: number; totalRequired: number; matchPct: number; isPotentialMatch: boolean; } const skillsMatch = (candSkill: string, jobSkill: string): boolean => { const c = candSkill.toLowerCase().trim(); const j = jobSkill.toLowerCase().trim(); if (c === j) return true; if (c.includes(j) || j.includes(c)) return true; const cWords = c.split(/[\s,./()&+-]+/).filter(w => w.length > 2); const jWords = j.split(/[\s,./()&+-]+/).filter(w => w.length > 2); const stopWords = ['and', 'for', 'with', 'the', 'management', 'administration', 'development', 'developer', 'engineer', 'system', 'systems', 'integration', 'operations', 'knowledge', 'experience', 'expert', 'proficiency', 'proficient']; const sharedWords = cWords.filter(w => jWords.includes(w) && !stopWords.includes(w)); return sharedWords.length > 0; }; interface UploadFileStatus { name: string; status: "uploading" | "success" | "error"; errorMessage?: string; } interface DuplicateState { fileName: string; existingCandidate: { id: string; name: string; contact_info: { email: string; phone: string; skills?: string[]; summary?: string; }; }; newProfile: { candidateName?: string; name?: string; email?: string; phone?: string; skills?: string[]; summary?: string; }; comparison: { en: string; es: string; } | null; onResolve: (action: "overwrite" | "ignore" | "cancel") => void; } export default function JobsPage() { const [jobs, setJobs] = useState([]); const [selectedJob, setSelectedJob] = useState(null); const [matches, setMatches] = useState([]); const [loadingJobs, setLoadingJobs] = useState(true); const [loadingMatches, setLoadingMatches] = useState(false); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [uploadSuccess, setUploadSuccess] = useState(null); const [uploadStatuses, setUploadStatuses] = useState([]); const [duplicateData, setDuplicateData] = useState(null); // Evaluation states const [evaluatingIds, setEvaluatingIds] = useState>({}); const [isBulkEvaluating, setIsBulkEvaluating] = useState(false); const [bulkEvalProgress, setBulkEvalProgress] = useState(""); // Promotion states const [promotingIds, setPromotingIds] = useState>({}); // Display states const [showHiddenCandidates, setShowHiddenCandidates] = useState(false); // Form states const [newTitle, setNewTitle] = useState(""); const [newRequirements, setNewRequirements] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [formError, setFormError] = useState(null); // Fetch all jobs on mount useEffect(() => { let active = true; fetch("/api/jobs") .then((res) => { if (!res.ok) throw new Error("Failed to fetch jobs"); return res.json(); }) .then((data) => { if (active) { setJobs(data); setLoadingJobs(false); if (data.length > 0) { setSelectedJob(data[0]); } } }) .catch((err) => { console.error(err); if (active) { setLoadingJobs(false); } }); return () => { active = false; }; }, []); // Fetch candidates/matches when selected job changes useEffect(() => { let active = true; Promise.resolve().then(() => { if (active) setUploadStatuses([]); }); if (!selectedJob) { Promise.resolve().then(() => { if (active) setMatches([]); }); return; } Promise.resolve().then(() => { if (active) setLoadingMatches(true); }); fetch(`/api/candidates?jobId=${selectedJob.id}`) .then((res) => { if (!res.ok) throw new Error("Failed to fetch candidate matches"); return res.json(); }) .then((data) => { if (active) { setMatches(data); setLoadingMatches(false); } }) .catch((err) => { console.error(err); if (active) { setLoadingMatches(false); } }); return () => { active = false; }; }, [selectedJob]); // Create vacancy const handleCreateJob = async (e: React.FormEvent) => { e.preventDefault(); if (!newTitle.trim() || !newRequirements.trim()) { setFormError("All fields are required"); return; } try { setIsSubmitting(true); setFormError(null); const res = await fetch("/api/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newTitle, requirements: newRequirements }), }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || "Failed to create vacancy"); } const newJob = await res.json(); setJobs((prev) => [newJob, ...prev]); setSelectedJob(newJob); setNewTitle(""); setNewRequirements(""); } catch (err: unknown) { setFormError(err instanceof Error ? err.message : "Error creating job"); } finally { setIsSubmitting(false); } }; // Upload PDF CVs const handleFileUpload = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0 || !selectedJob) return; const fileList = Array.from(files); const initialStatuses = fileList.map((file) => ({ name: file.name, status: "uploading" as const, })); setUploadStatuses(initialStatuses); setUploading(true); setUploadError(null); setUploadSuccess(null); for (let i = 0; i < fileList.length; i++) { const file = fileList[i]; if (file.type !== "application/pdf") { setUploadStatuses((prev) => prev.map((status, idx) => idx === i ? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" } : status ) ); continue; } let currentAction = "check"; let done = false; while (!done) { try { const formData = new FormData(); formData.append("file", file); formData.append("duplicateAction", currentAction); formData.append("jobId", selectedJob.id); const res = await fetch("/candidates/api/parse-cv", { method: "POST", body: formData, }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || "Failed to process CV"); } const data = await res.json(); if (data.isDuplicate) { // Trigger AI Comparison summary let comparisonResult = null; try { const compRes = await fetch("/api/candidates/compare", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ existingProfile: data.existingCandidate, newProfile: data.newProfile, }), }); if (compRes.ok) { const compData = await compRes.json(); comparisonResult = compData.comparison; } } catch (compErr) { console.error("Comparison request failed:", compErr); } // Pause and wait for user's decision const userAction = await new Promise<"overwrite" | "ignore" | "cancel">((resolve) => { setDuplicateData({ fileName: file.name, existingCandidate: data.existingCandidate, newProfile: data.newProfile, comparison: comparisonResult, onResolve: resolve, }); }); // Close dialog setDuplicateData(null); if (userAction === "cancel") { setUploadStatuses((prev) => prev.map((status, idx) => idx === i ? { ...status, status: "error" as const, errorMessage: "Upload cancelled by user" } : status ) ); done = true; } else { // Resend request with overwrite or ignore parameter currentAction = userAction === "overwrite" ? "overwrite" : "ignore"; } } else { // Success setUploadStatuses((prev) => prev.map((status, idx) => idx === i ? { ...status, status: "success" as const } : status ) ); done = true; } } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Error uploading CV"; setUploadStatuses((prev) => prev.map((status, idx) => idx === i ? { ...status, status: "error" as const, errorMessage: msg } : status ) ); done = true; } } } setUploading(false); // Refresh matches for current job const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`); if (matchesRes.ok) { const matchesData = await matchesRes.json(); setMatches(matchesData); } e.target.value = ""; }; // Run deep AI evaluation via backend endpoint const handleEvaluate = async (candidateId: string) => { if (!selectedJob) return; try { setEvaluatingIds((prev) => ({ ...prev, [candidateId]: true })); const res = await fetch("/candidates/api/evaluate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ candidateId, jobId: selectedJob.id }), }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || "Failed to trigger evaluation"); } // Refresh matches for current job const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`); if (matchesRes.ok) { const matchesData = await matchesRes.json(); setMatches(matchesData); } } catch (err: unknown) { alert(err instanceof Error ? err.message : "Error evaluating candidate"); } finally { setEvaluatingIds((prev) => ({ ...prev, [candidateId]: false })); } }; // Bulk evaluate visible matches without scores sequentially const handleBulkEvaluate = async () => { if (!selectedJob) return; // Find all visible candidates without scores const candidatesToEval = matches.filter((match) => { const jobSkills = selectedJob.requirements.skills || []; const candidateSkills = match.contact_info.skills || []; const matchedSkills = jobSkills.filter((js) => candidateSkills.some((cs) => skillsMatch(cs, js)) ); const matchPct = jobSkills.length > 0 ? Math.round((matchedSkills.length / jobSkills.length) * 100) : 0; const isPotentialMatch = matchPct >= 75; const latestScore = match.scores?.[0]; return isPotentialMatch && !latestScore; }); if (candidatesToEval.length === 0) { alert("No candidates to evaluate."); return; } try { setIsBulkEvaluating(true); for (let i = 0; i < candidatesToEval.length; i++) { const candidate = candidatesToEval[i]; setBulkEvalProgress(`Evaluating ${i + 1} of ${candidatesToEval.length} (${candidate.name})...`); const res = await fetch("/candidates/api/evaluate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ candidateId: candidate.id, jobId: selectedJob.id }), }); if (!res.ok) { console.error(`Failed to evaluate ${candidate.name}`); } } setBulkEvalProgress("All evaluations completed!"); setTimeout(() => setBulkEvalProgress(""), 3000); // Refresh matches for current job const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`); if (matchesRes.ok) { const matchesData = await matchesRes.json(); setMatches(matchesData); } } catch (err: unknown) { alert(err instanceof Error ? err.message : "Error bulk evaluating candidates"); } finally { setIsBulkEvaluating(false); } }; // Promote candidate to interviews const handlePromote = async (candidateId: string) => { if (!selectedJob) return; try { setPromotingIds((prev) => ({ ...prev, [candidateId]: true })); const res = await fetch("/candidates/api/promote", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ candidateId, jobId: selectedJob.id }), }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || "Failed to promote candidate"); } const resData = await res.json(); // Update local state to reflect that the candidate is now promoted setMatches((prev) => prev.map((match) => match.id === candidateId ? { ...match, interview: { id: resData.interviewId, stage: "Screening", interview_date: new Date().toISOString(), feedback: null, }, } : match ) ); } catch (err: unknown) { alert(err instanceof Error ? err.message : "Error promoting candidate"); } finally { setPromotingIds((prev) => ({ ...prev, [candidateId]: false })); } }; return (
{/* Left Column: Create Form & Vacancies List */}
{/* Create vacancy form */}

Create Vacancy

setNewTitle(e.target.value)} placeholder="e.g., Senior React Developer" 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" required />