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
|
||||
*.tsbuildinfo
|
||||
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 || "";
|
||||
|
||||
// 3. Fetch or create interview record
|
||||
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
|
||||
// 3. Call n8n webhook passing jobId instead of interviewId
|
||||
let n8nResponseData = null;
|
||||
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
|
||||
|
||||
|
|
@ -110,7 +70,7 @@ export async function POST(request: NextRequest) {
|
|||
},
|
||||
body: JSON.stringify({
|
||||
candidateId,
|
||||
interviewId,
|
||||
jobId,
|
||||
candidateName,
|
||||
candidateEmail: email,
|
||||
text: cvText,
|
||||
|
|
@ -141,7 +101,7 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({
|
||||
success: true,
|
||||
candidateId,
|
||||
interviewId,
|
||||
jobId,
|
||||
candidateName,
|
||||
n8nResponse: n8nResponseData,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ export async function POST(request: NextRequest) {
|
|||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const jobId = formData.get("jobId") as string | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||
}
|
||||
|
|
@ -72,20 +70,8 @@ export async function POST(request: NextRequest) {
|
|||
candidateId = candidate.id;
|
||||
candidateName = candidate.name;
|
||||
|
||||
// Create an initial interview record if jobId is provided (but do not trigger n8n evaluate webhook yet)
|
||||
if (jobId) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Decoupled: We no longer create an initial interview record on upload.
|
||||
// Interviews are only queued when recruiter manually takes action.
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
};
|
||||
|
||||
export default function JobsPage() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||
|
|
@ -52,6 +82,14 @@ export default function JobsPage() {
|
|||
|
||||
// Evaluation states
|
||||
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
|
||||
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 (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column: Create Form & Vacancies List */}
|
||||
|
|
@ -391,54 +524,60 @@ export default function JobsPage() {
|
|||
|
||||
{/* Matches List */}
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-900 mb-3">
|
||||
Candidates & Compatibility Index
|
||||
</h3>
|
||||
{loadingMatches ? (
|
||||
<p className="text-slate-500 text-sm">Finding matches...</p>
|
||||
) : matches.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">
|
||||
No candidates have been uploaded or matched yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{matches.map((match) => {
|
||||
const similarityPct = match.similarity
|
||||
? Math.round(match.similarity * 100)
|
||||
: null;
|
||||
const latestScore = match.scores?.[0];
|
||||
|
||||
// Programmatic skills matching logic
|
||||
{/* Computed lists */}
|
||||
{(() => {
|
||||
const getSkillsOverlap = (match: Candidate) => {
|
||||
const jobSkills = selectedJob.requirements.skills || [];
|
||||
const candidateSkills = match.contact_info.skills || [];
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const matchedSkills = jobSkills.filter(js =>
|
||||
candidateSkills.some(cs => skillsMatch(cs, js))
|
||||
const matchedSkills = jobSkills.filter((js) =>
|
||||
candidateSkills.some((cs) => skillsMatch(cs, js))
|
||||
);
|
||||
const missingSkills = 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 {
|
||||
matchedSkills,
|
||||
missingSkills,
|
||||
overlapCount,
|
||||
totalRequired,
|
||||
matchPct,
|
||||
isPotentialMatch,
|
||||
};
|
||||
};
|
||||
|
||||
const visibleMatches: { candidate: Candidate; overlap: SkillsOverlap }[] = [];
|
||||
const hiddenMatches: { candidate: Candidate; overlap: SkillsOverlap }[] = [];
|
||||
|
||||
matches.forEach((match) => {
|
||||
const overlap = getSkillsOverlap(match);
|
||||
const latestScore = match.scores?.[0];
|
||||
const isUnqualified = latestScore?.evaluation.classification === "Unqualified";
|
||||
|
||||
if (!overlap.isPotentialMatch || isUnqualified) {
|
||||
hiddenMatches.push({ candidate: match, overlap });
|
||||
} else {
|
||||
visibleMatches.push({ candidate: match, overlap });
|
||||
}
|
||||
});
|
||||
|
||||
const visibleMatchesToEval = visibleMatches.filter(
|
||||
({ candidate }) => !candidate.scores?.[0]
|
||||
);
|
||||
|
||||
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}
|
||||
|
|
@ -487,7 +626,7 @@ export default function JobsPage() {
|
|||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Display matched skills in green */}
|
||||
{matchedSkills.map(skill => (
|
||||
{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"
|
||||
|
|
@ -497,7 +636,7 @@ export default function JobsPage() {
|
|||
))}
|
||||
|
||||
{/* Display missing skills in light red/gray dashed */}
|
||||
{missingSkills.map(skill => (
|
||||
{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"
|
||||
|
|
@ -517,7 +656,7 @@ export default function JobsPage() {
|
|||
|
||||
{/* 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>
|
||||
<div className="flex-1">
|
||||
{latestScore ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-slate-500">
|
||||
|
|
@ -539,25 +678,138 @@ export default function JobsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="self-end sm:self-center">
|
||||
<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]}
|
||||
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"
|
||||
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 (n8n)..."
|
||||
? "Evaluating..."
|
||||
: latestScore
|
||||
? "Re-run Deep AI"
|
||||
: "Run Deep AI Evaluation"}
|
||||
? "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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -14,15 +14,25 @@ interface CandidateScore {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
interface InterviewDetail {
|
||||
id: string;
|
||||
stage: string;
|
||||
interview_date: string;
|
||||
feedback: string | null;
|
||||
}
|
||||
|
||||
interface RankedCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
contact_info: {
|
||||
email: string;
|
||||
phone: string;
|
||||
skills?: string[];
|
||||
summary?: string;
|
||||
};
|
||||
similarity?: number;
|
||||
scores?: CandidateScore[];
|
||||
interview?: InterviewDetail | null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
@ -62,16 +72,29 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
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) {
|
||||
const candidateIds = candidatesList.map((c) => c.id);
|
||||
const { data: scores, error: scoresError } = await supabase
|
||||
.from("scores")
|
||||
.select("*, interviews!inner(job_id)")
|
||||
.select("*")
|
||||
.in("candidate_id", candidateIds)
|
||||
.eq("interviews.job_id", jobId)
|
||||
.eq("job_id", jobId)
|
||||
.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) {
|
||||
const typedScores = (scores as unknown as CandidateScore[]) || [];
|
||||
|
||||
|
|
@ -109,10 +132,12 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
candidatesList.forEach((c) => {
|
||||
c.scores = scoresMap.get(c.id) || [];
|
||||
c.interview = interviewsMap.get(c.id) || null;
|
||||
});
|
||||
} else {
|
||||
candidatesList.forEach((c) => {
|
||||
c.scores = [];
|
||||
c.interview = interviewsMap.get(c.id) || null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,8 +345,8 @@ async function main() {
|
|||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "interview_id",
|
||||
value: "={{ $('Webhook Trigger').item.json.body.interviewId }}",
|
||||
name: "job_id",
|
||||
value: "={{ $('Webhook Trigger').item.json.body.jobId }}",
|
||||
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