feat: scale up candidate evaluation and promotion
This commit is contained in:
parent
24c88915b0
commit
529958f492
8 changed files with 516 additions and 221 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -40,3 +40,6 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# agents config
|
||||||
|
.agents/
|
||||||
|
|
|
||||||
|
|
@ -57,47 +57,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const jobRequirementsText = (job.requirements as { text?: string })?.text || "";
|
const jobRequirementsText = (job.requirements as { text?: string })?.text || "";
|
||||||
|
|
||||||
// 3. Fetch or create interview record
|
// 3. Call n8n webhook passing jobId instead of interviewId
|
||||||
let interviewId = "";
|
|
||||||
const { data: existingInterviews, error: fetchInterviewError } = await supabase
|
|
||||||
.from("interviews")
|
|
||||||
.select("id")
|
|
||||||
.eq("candidate_id", candidateId)
|
|
||||||
.eq("job_id", jobId)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (fetchInterviewError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: fetchInterviewError.message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingInterviews && existingInterviews.length > 0) {
|
|
||||||
interviewId = existingInterviews[0].id;
|
|
||||||
} else {
|
|
||||||
// Create new interview
|
|
||||||
const { data: newInterview, error: insertInterviewError } = await supabase
|
|
||||||
.from("interviews")
|
|
||||||
.insert({
|
|
||||||
candidate_id: candidateId,
|
|
||||||
job_id: jobId,
|
|
||||||
interview_date: new Date().toISOString(),
|
|
||||||
stage: "Screening",
|
|
||||||
})
|
|
||||||
.select("id")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (insertInterviewError || !newInterview) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: insertInterviewError?.message || "Failed to create interview record" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
interviewId = newInterview.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Call n8n webhook
|
|
||||||
let n8nResponseData = null;
|
let n8nResponseData = null;
|
||||||
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
|
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
|
||||||
|
|
||||||
|
|
@ -110,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
candidateId,
|
candidateId,
|
||||||
interviewId,
|
jobId,
|
||||||
candidateName,
|
candidateName,
|
||||||
candidateEmail: email,
|
candidateEmail: email,
|
||||||
text: cvText,
|
text: cvText,
|
||||||
|
|
@ -141,7 +101,7 @@ export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
candidateId,
|
candidateId,
|
||||||
interviewId,
|
jobId,
|
||||||
candidateName,
|
candidateName,
|
||||||
n8nResponse: n8nResponseData,
|
n8nResponse: n8nResponseData,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
const jobId = formData.get("jobId") as string | null;
|
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
@ -72,20 +70,8 @@ export async function POST(request: NextRequest) {
|
||||||
candidateId = candidate.id;
|
candidateId = candidate.id;
|
||||||
candidateName = candidate.name;
|
candidateName = candidate.name;
|
||||||
|
|
||||||
// Create an initial interview record if jobId is provided (but do not trigger n8n evaluate webhook yet)
|
// Decoupled: We no longer create an initial interview record on upload.
|
||||||
if (jobId) {
|
// Interviews are only queued when recruiter manually takes action.
|
||||||
const { error: interviewError } = await supabase
|
|
||||||
.from("interviews")
|
|
||||||
.insert({
|
|
||||||
candidate_id: candidate.id,
|
|
||||||
job_id: jobId,
|
|
||||||
interview_date: new Date().toISOString(),
|
|
||||||
stage: "Screening",
|
|
||||||
});
|
|
||||||
if (interviewError) {
|
|
||||||
console.error("Failed to insert interview:", interviewError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
|
||||||
67
app/(dashboard)/candidates/api/promote/route.ts
Normal file
67
app/(dashboard)/candidates/api/promote/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createServerSupabaseClient } from "@/lib/supabase";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { candidateId, jobId } = body;
|
||||||
|
|
||||||
|
if (!candidateId || !jobId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "candidateId and jobId are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createServerSupabaseClient();
|
||||||
|
|
||||||
|
// 1. Check if interview record already exists
|
||||||
|
const { data: existingInterviews, error: fetchError } = await supabase
|
||||||
|
.from("interviews")
|
||||||
|
.select("id")
|
||||||
|
.eq("candidate_id", candidateId)
|
||||||
|
.eq("job_id", jobId)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
return NextResponse.json({ error: fetchError.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingInterviews && existingInterviews.length > 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Candidate already promoted to interviews.",
|
||||||
|
interviewId: existingInterviews[0].id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Insert new interview record
|
||||||
|
const { data: newInterview, error: insertError } = await supabase
|
||||||
|
.from("interviews")
|
||||||
|
.insert({
|
||||||
|
candidate_id: candidateId,
|
||||||
|
job_id: jobId,
|
||||||
|
interview_date: new Date().toISOString(),
|
||||||
|
stage: "Screening",
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (insertError || !newInterview) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: insertError?.message || "Failed to create interview record" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Candidate successfully promoted to interviews.",
|
||||||
|
interviewId: newInterview.id,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("Error in promote API:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Internal server error";
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,9 +36,39 @@ interface Candidate {
|
||||||
};
|
};
|
||||||
similarity?: number;
|
similarity?: number;
|
||||||
scores?: Score[];
|
scores?: Score[];
|
||||||
|
interview?: {
|
||||||
|
id: string;
|
||||||
|
stage: string;
|
||||||
|
interview_date: string;
|
||||||
|
feedback: string | null;
|
||||||
|
} | null;
|
||||||
created_at: string;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -52,6 +82,14 @@ export default function JobsPage() {
|
||||||
|
|
||||||
// Evaluation states
|
// Evaluation states
|
||||||
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
|
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
|
||||||
|
const [isBulkEvaluating, setIsBulkEvaluating] = useState(false);
|
||||||
|
const [bulkEvalProgress, setBulkEvalProgress] = useState("");
|
||||||
|
|
||||||
|
// Promotion states
|
||||||
|
const [promotingIds, setPromotingIds] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// Display states
|
||||||
|
const [showHiddenCandidates, setShowHiddenCandidates] = useState(false);
|
||||||
|
|
||||||
// Form states
|
// Form states
|
||||||
const [newTitle, setNewTitle] = useState("");
|
const [newTitle, setNewTitle] = useState("");
|
||||||
|
|
@ -232,6 +270,101 @@ export default function JobsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Left Column: Create Form & Vacancies List */}
|
{/* Left Column: Create Form & Vacancies List */}
|
||||||
|
|
@ -391,173 +524,292 @@ export default function JobsPage() {
|
||||||
|
|
||||||
{/* Matches List */}
|
{/* Matches List */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-slate-900 mb-3">
|
{/* Computed lists */}
|
||||||
Candidates & Compatibility Index
|
{(() => {
|
||||||
</h3>
|
const getSkillsOverlap = (match: Candidate) => {
|
||||||
{loadingMatches ? (
|
const jobSkills = selectedJob.requirements.skills || [];
|
||||||
<p className="text-slate-500 text-sm">Finding matches...</p>
|
const candidateSkills = match.contact_info.skills || [];
|
||||||
) : matches.length === 0 ? (
|
const matchedSkills = jobSkills.filter((js) =>
|
||||||
<p className="text-slate-500 text-sm">
|
candidateSkills.some((cs) => skillsMatch(cs, js))
|
||||||
No candidates have been uploaded or matched yet.
|
);
|
||||||
</p>
|
const missingSkills = jobSkills.filter((js) =>
|
||||||
) : (
|
!candidateSkills.some((cs) => skillsMatch(cs, js))
|
||||||
<div className="flex flex-col gap-4">
|
);
|
||||||
{matches.map((match) => {
|
const overlapCount = matchedSkills.length;
|
||||||
const similarityPct = match.similarity
|
const totalRequired = jobSkills.length;
|
||||||
? Math.round(match.similarity * 100)
|
const matchPct = totalRequired > 0 ? Math.round((overlapCount / totalRequired) * 100) : 0;
|
||||||
: null;
|
const isPotentialMatch = matchPct >= 75;
|
||||||
const latestScore = match.scores?.[0];
|
|
||||||
|
|
||||||
// Programmatic skills matching logic
|
return {
|
||||||
const jobSkills = selectedJob.requirements.skills || [];
|
matchedSkills,
|
||||||
const candidateSkills = match.contact_info.skills || [];
|
missingSkills,
|
||||||
|
overlapCount,
|
||||||
|
totalRequired,
|
||||||
|
matchPct,
|
||||||
|
isPotentialMatch,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const skillsMatch = (candSkill: string, jobSkill: string): boolean => {
|
const visibleMatches: { candidate: Candidate; overlap: SkillsOverlap }[] = [];
|
||||||
const c = candSkill.toLowerCase().trim();
|
const hiddenMatches: { candidate: Candidate; overlap: SkillsOverlap }[] = [];
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const matchedSkills = jobSkills.filter(js =>
|
|
||||||
candidateSkills.some(cs => skillsMatch(cs, js))
|
|
||||||
);
|
|
||||||
const missingSkills = jobSkills.filter(js =>
|
|
||||||
!candidateSkills.some(cs => skillsMatch(cs, js))
|
|
||||||
);
|
|
||||||
|
|
||||||
const overlapCount = matchedSkills.length;
|
|
||||||
const totalRequired = jobSkills.length;
|
|
||||||
const matchPct = totalRequired > 0 ? Math.round((overlapCount / totalRequired) * 100) : 0;
|
|
||||||
const isPotentialMatch = matchPct >= 75;
|
|
||||||
|
|
||||||
return (
|
matches.forEach((match) => {
|
||||||
<div
|
const overlap = getSkillsOverlap(match);
|
||||||
key={match.id}
|
const latestScore = match.scores?.[0];
|
||||||
className="p-5 rounded-lg border border-slate-200 bg-white flex flex-col gap-4 shadow-sm hover:border-slate-300 transition duration-200"
|
const isUnqualified = latestScore?.evaluation.classification === "Unqualified";
|
||||||
>
|
|
||||||
{/* Upper info panel */}
|
if (!overlap.isPotentialMatch || isUnqualified) {
|
||||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
hiddenMatches.push({ candidate: match, overlap });
|
||||||
<div className="flex flex-col gap-1">
|
} else {
|
||||||
<div className="text-slate-900 font-bold text-base">
|
visibleMatches.push({ candidate: match, overlap });
|
||||||
{match.name}
|
}
|
||||||
</div>
|
});
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
Email: <span className="text-slate-700 font-medium mr-3">{match.contact_info.email}</span>
|
const visibleMatchesToEval = visibleMatches.filter(
|
||||||
Phone: <span className="text-slate-700 font-medium">{match.contact_info.phone}</span>
|
({ candidate }) => !candidate.scores?.[0]
|
||||||
</div>
|
);
|
||||||
|
|
||||||
|
const renderCandidateCard = (match: Candidate, overlap: SkillsOverlap) => {
|
||||||
|
const similarityPct = match.similarity
|
||||||
|
? Math.round(match.similarity * 100)
|
||||||
|
: null;
|
||||||
|
const latestScore = match.scores?.[0];
|
||||||
|
const { matchedSkills, missingSkills, overlapCount, totalRequired, matchPct, isPotentialMatch } = overlap;
|
||||||
|
const isUnqualified = latestScore?.evaluation.classification === "Unqualified";
|
||||||
|
const jobSkills = selectedJob.requirements.skills || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={match.id}
|
||||||
|
className="p-5 rounded-lg border border-slate-200 bg-white flex flex-col gap-4 shadow-sm hover:border-slate-300 transition duration-200"
|
||||||
|
>
|
||||||
|
{/* Upper info panel */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-slate-900 font-bold text-base">
|
||||||
|
{match.name}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
Email: <span className="text-slate-700 font-medium mr-3">{match.contact_info.email}</span>
|
||||||
{/* Pre-selection status badge */}
|
Phone: <span className="text-slate-700 font-medium">{match.contact_info.phone}</span>
|
||||||
<span
|
|
||||||
className={`px-2.5 py-1 text-xs font-semibold rounded-md border ${
|
|
||||||
isPotentialMatch
|
|
||||||
? "bg-green-50 text-green-700 border-green-200"
|
|
||||||
: "bg-slate-50 text-slate-500 border-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPotentialMatch
|
|
||||||
? `Potential Match (${matchPct}% overlap)`
|
|
||||||
: `Skill Mismatch (${matchPct}% overlap)`}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Semantic embedding similarity badge */}
|
|
||||||
{similarityPct !== null && (
|
|
||||||
<span className="px-2.5 py-1 text-xs font-semibold rounded-md border bg-blue-50 text-blue-700 border-blue-200">
|
|
||||||
Semantic: {similarityPct}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skills overlap details */}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="bg-slate-50 p-3 rounded-md border border-slate-100 flex flex-col gap-2">
|
{/* Pre-selection status badge */}
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<span
|
||||||
Skills Check: {overlapCount} of {totalRequired} matching
|
className={`px-2.5 py-1 text-xs font-semibold rounded-md border ${
|
||||||
</div>
|
isPotentialMatch
|
||||||
|
? "bg-green-50 text-green-700 border-green-200"
|
||||||
|
: "bg-slate-50 text-slate-500 border-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPotentialMatch
|
||||||
|
? `Potential Match (${matchPct}% overlap)`
|
||||||
|
: `Skill Mismatch (${matchPct}% overlap)`}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
{/* Semantic embedding similarity badge */}
|
||||||
{/* Display matched skills in green */}
|
{similarityPct !== null && (
|
||||||
{matchedSkills.map(skill => (
|
<span className="px-2.5 py-1 text-xs font-semibold rounded-md border bg-blue-50 text-blue-700 border-blue-200">
|
||||||
<span
|
Semantic: {similarityPct}%
|
||||||
key={skill}
|
</span>
|
||||||
className="px-2 py-0.5 bg-green-100 text-green-800 border border-green-200 text-xs rounded-md font-medium"
|
)}
|
||||||
>
|
|
||||||
{skill}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Display missing skills in light red/gray dashed */}
|
|
||||||
{missingSkills.map(skill => (
|
|
||||||
<span
|
|
||||||
key={skill}
|
|
||||||
className="px-2 py-0.5 bg-white border border-slate-200 border-dashed text-slate-400 text-xs rounded-md"
|
|
||||||
>
|
|
||||||
{skill} (missing)
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Fallback if no skills are loaded */}
|
|
||||||
{jobSkills.length === 0 && (
|
|
||||||
<span className="text-xs text-slate-500 italic">
|
|
||||||
No required skills extracted for this job yet.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom evaluation / action panel */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pt-3 border-t border-slate-100">
|
|
||||||
<div>
|
|
||||||
{latestScore ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
AI ASSESSMENT RESULT
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-slate-700 font-medium">
|
|
||||||
Decision: <span className="font-bold text-slate-900">{latestScore.evaluation.classification}</span>
|
|
||||||
<span className="mx-2 font-normal text-slate-300">|</span>
|
|
||||||
Score: <span className="font-bold text-blue-600 text-base">{latestScore.ai_score} / 100</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-500 leading-normal max-w-lg mt-1">
|
|
||||||
{latestScore.evaluation.summary}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-slate-500 italic">
|
|
||||||
Ready for deep assessment. Only potential matches recommended for LLM budget optimization.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="self-end sm:self-center">
|
|
||||||
<button
|
|
||||||
onClick={() => handleEvaluate(match.id)}
|
|
||||||
disabled={evaluatingIds[match.id]}
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold rounded-md transition duration-200 disabled:opacity-50 shadow-sm"
|
|
||||||
>
|
|
||||||
{evaluatingIds[match.id]
|
|
||||||
? "Evaluating (n8n)..."
|
|
||||||
: latestScore
|
|
||||||
? "Re-run Deep AI"
|
|
||||||
: "Run Deep AI Evaluation"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
{/* Skills overlap details */}
|
||||||
</div>
|
<div className="bg-slate-50 p-3 rounded-md border border-slate-100 flex flex-col gap-2">
|
||||||
)}
|
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
Skills Check: {overlapCount} of {totalRequired} matching
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{/* Display matched skills in green */}
|
||||||
|
{matchedSkills.map((skill: string) => (
|
||||||
|
<span
|
||||||
|
key={skill}
|
||||||
|
className="px-2 py-0.5 bg-green-100 text-green-800 border border-green-200 text-xs rounded-md font-medium"
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Display missing skills in light red/gray dashed */}
|
||||||
|
{missingSkills.map((skill: string) => (
|
||||||
|
<span
|
||||||
|
key={skill}
|
||||||
|
className="px-2 py-0.5 bg-white border border-slate-200 border-dashed text-slate-400 text-xs rounded-md"
|
||||||
|
>
|
||||||
|
{skill} (missing)
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fallback if no skills are loaded */}
|
||||||
|
{jobSkills.length === 0 && (
|
||||||
|
<span className="text-xs text-slate-500 italic">
|
||||||
|
No required skills extracted for this job yet.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom evaluation / action panel */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pt-3 border-t border-slate-100">
|
||||||
|
<div className="flex-1">
|
||||||
|
{latestScore ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
AI ASSESSMENT RESULT
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-700 font-medium">
|
||||||
|
Decision: <span className="font-bold text-slate-900">{latestScore.evaluation.classification}</span>
|
||||||
|
<span className="mx-2 font-normal text-slate-300">|</span>
|
||||||
|
Score: <span className="font-bold text-blue-600 text-base">{latestScore.ai_score} / 100</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 leading-normal max-w-lg mt-1">
|
||||||
|
{latestScore.evaluation.summary}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-slate-500 italic">
|
||||||
|
Ready for deep assessment. Only potential matches recommended for LLM budget optimization.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 self-end sm:self-center">
|
||||||
|
{/* Run/Re-run AI evaluation */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleEvaluate(match.id)}
|
||||||
|
disabled={evaluatingIds[match.id] || isBulkEvaluating}
|
||||||
|
className="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-md border border-slate-200 transition duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{evaluatingIds[match.id]
|
||||||
|
? "Evaluating..."
|
||||||
|
: latestScore
|
||||||
|
? "Re-run AI"
|
||||||
|
: "Run AI Evaluation"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Promote to Interview Pipeline */}
|
||||||
|
{match.interview ? (
|
||||||
|
<span className="px-3 py-1.5 bg-green-50 border border-green-200 text-green-700 text-xs font-semibold rounded-md">
|
||||||
|
Promoted ({match.interview.stage})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handlePromote(match.id)}
|
||||||
|
disabled={
|
||||||
|
promotingIds[match.id] ||
|
||||||
|
isBulkEvaluating ||
|
||||||
|
isUnqualified ||
|
||||||
|
!isPotentialMatch
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold rounded-md transition duration-200 disabled:opacity-50 disabled:bg-slate-100 disabled:text-slate-400 disabled:border disabled:border-slate-200"
|
||||||
|
title={
|
||||||
|
isUnqualified
|
||||||
|
? "Cannot promote unqualified candidates"
|
||||||
|
: !isPotentialMatch
|
||||||
|
? "Skill overlap too low to promote"
|
||||||
|
: "Promote to Interviews"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{promotingIds[match.id] ? "Promoting..." : "Promote to Interviews"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Toolbar / Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-600 text-sm font-semibold">
|
||||||
|
Showing {visibleMatches.length} qualified matches
|
||||||
|
</span>
|
||||||
|
{visibleMatchesToEval.length > 0 && (
|
||||||
|
<span className="text-xs text-slate-500 font-medium">
|
||||||
|
({visibleMatchesToEval.length} unevaluated)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibleMatchesToEval.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleBulkEvaluate}
|
||||||
|
disabled={isBulkEvaluating}
|
||||||
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold rounded-md transition duration-200 disabled:opacity-50 shadow-sm flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{isBulkEvaluating ? (
|
||||||
|
<>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-white animate-ping"></span>
|
||||||
|
{bulkEvalProgress || "Evaluating..."}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Bulk Run AI Evaluation (${visibleMatchesToEval.length})`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visible Matches List */}
|
||||||
|
{loadingMatches ? (
|
||||||
|
<p className="text-slate-500 text-sm">Finding matches...</p>
|
||||||
|
) : visibleMatches.length === 0 && !loadingMatches ? (
|
||||||
|
<div className="p-8 text-center border border-slate-100 rounded-lg bg-slate-50/50">
|
||||||
|
<p className="text-slate-500 text-sm font-medium">No active potential matches found.</p>
|
||||||
|
<p className="text-slate-400 text-xs mt-1">Upload CVs or check the mismatch/unqualified list below.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{visibleMatches.map(({ candidate, overlap }) =>
|
||||||
|
renderCandidateCard(candidate, overlap)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expandable Hidden Matches List */}
|
||||||
|
{hiddenMatches.length > 0 && (
|
||||||
|
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHiddenCandidates(!showHiddenCandidates)}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-slate-50 hover:bg-slate-100 transition duration-200 border-b border-slate-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-slate-700 font-semibold text-sm">
|
||||||
|
<span>Mismatched or Unqualified Candidates</span>
|
||||||
|
<span className="px-2 py-0.5 bg-slate-200 text-slate-800 text-xs rounded-full font-bold">
|
||||||
|
{hiddenMatches.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 text-slate-500 transform transition-transform duration-200 ${
|
||||||
|
showHiddenCandidates ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showHiddenCandidates && (
|
||||||
|
<div className="p-4 bg-slate-50/50 border-t border-slate-200 flex flex-col gap-4">
|
||||||
|
{hiddenMatches.map(({ candidate, overlap }) =>
|
||||||
|
renderCandidateCard(candidate, overlap)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,25 @@ interface CandidateScore {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InterviewDetail {
|
||||||
|
id: string;
|
||||||
|
stage: string;
|
||||||
|
interview_date: string;
|
||||||
|
feedback: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface RankedCandidate {
|
interface RankedCandidate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
contact_info: {
|
contact_info: {
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
skills?: string[];
|
||||||
|
summary?: string;
|
||||||
};
|
};
|
||||||
similarity?: number;
|
similarity?: number;
|
||||||
scores?: CandidateScore[];
|
scores?: CandidateScore[];
|
||||||
|
interview?: InterviewDetail | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|
@ -62,16 +72,29 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const candidatesList = (rankedCandidates as unknown as RankedCandidate[]) || [];
|
const candidatesList = (rankedCandidates as unknown as RankedCandidate[]) || [];
|
||||||
|
|
||||||
// 3. Fetch scores for these matched candidates to return AI scores/details
|
// 3. Fetch scores and interviews for these matched candidates
|
||||||
if (candidatesList.length > 0) {
|
if (candidatesList.length > 0) {
|
||||||
const candidateIds = candidatesList.map((c) => c.id);
|
const candidateIds = candidatesList.map((c) => c.id);
|
||||||
const { data: scores, error: scoresError } = await supabase
|
const { data: scores, error: scoresError } = await supabase
|
||||||
.from("scores")
|
.from("scores")
|
||||||
.select("*, interviews!inner(job_id)")
|
.select("*")
|
||||||
.in("candidate_id", candidateIds)
|
.in("candidate_id", candidateIds)
|
||||||
.eq("interviews.job_id", jobId)
|
.eq("job_id", jobId)
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
const { data: interviews, error: interviewsError } = await supabase
|
||||||
|
.from("interviews")
|
||||||
|
.select("*")
|
||||||
|
.in("candidate_id", candidateIds)
|
||||||
|
.eq("job_id", jobId);
|
||||||
|
|
||||||
|
const interviewsMap = new Map<string, InterviewDetail>();
|
||||||
|
if (!interviewsError && interviews) {
|
||||||
|
interviews.forEach((i) => {
|
||||||
|
interviewsMap.set(i.candidate_id, i as unknown as InterviewDetail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!scoresError && scores) {
|
if (!scoresError && scores) {
|
||||||
const typedScores = (scores as unknown as CandidateScore[]) || [];
|
const typedScores = (scores as unknown as CandidateScore[]) || [];
|
||||||
|
|
||||||
|
|
@ -109,10 +132,12 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
candidatesList.forEach((c) => {
|
candidatesList.forEach((c) => {
|
||||||
c.scores = scoresMap.get(c.id) || [];
|
c.scores = scoresMap.get(c.id) || [];
|
||||||
|
c.interview = interviewsMap.get(c.id) || null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
candidatesList.forEach((c) => {
|
candidatesList.forEach((c) => {
|
||||||
c.scores = [];
|
c.scores = [];
|
||||||
|
c.interview = interviewsMap.get(c.id) || null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -345,8 +345,8 @@ async function main() {
|
||||||
type: "string",
|
type: "string",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "interview_id",
|
name: "job_id",
|
||||||
value: "={{ $('Webhook Trigger').item.json.body.interviewId }}",
|
value: "={{ $('Webhook Trigger').item.json.body.jobId }}",
|
||||||
type: "string",
|
type: "string",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Add job_id to scores table to decouple AI evaluations from interviews
|
||||||
|
ALTER TABLE scores ADD COLUMN IF NOT EXISTS job_id UUID REFERENCES jobs(id) ON DELETE CASCADE;
|
||||||
Loading…
Reference in a new issue