Spaces:
Sleeping
Sleeping
| /** | |
| * RankingTable.jsx | |
| * Displays the ranked list of candidates with scores, badges, and selection status. | |
| * Each row is clickable to show the full detail panel. | |
| */ | |
| import { Trophy, ChevronRight, CheckCircle, XCircle, TrendingUp } from 'lucide-react'; | |
| import ScoreRing from './ScoreRing'; | |
| function SkillBadge({ skill, variant = 'green' }) { | |
| return ( | |
| <span className={`badge-${variant} mr-1 mb-1`}>{skill}</span> | |
| ); | |
| } | |
| function RankIcon({ rank }) { | |
| if (rank === 1) return <span className="text-lg">🥇</span>; | |
| if (rank === 2) return <span className="text-lg">🥈</span>; | |
| if (rank === 3) return <span className="text-lg">🥉</span>; | |
| return ( | |
| <span className="w-6 h-6 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-xs text-slate-400 font-semibold"> | |
| {rank} | |
| </span> | |
| ); | |
| } | |
| export default function RankingTable({ candidates, selectedId, onSelectCandidate, jobTitle }) { | |
| if (!candidates || candidates.length === 0) return null; | |
| return ( | |
| <div className="space-y-4 animate-slide-up"> | |
| {/* Table header */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h2 className="text-xl font-bold text-white flex items-center gap-2"> | |
| <Trophy size={20} className="text-amber-400" /> | |
| Ranked Candidates | |
| </h2> | |
| <p className="text-slate-400 text-sm mt-0.5"> | |
| {candidates.length} candidates screened for <span className="text-primary-300 font-medium">{jobTitle}</span> | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-3 text-xs text-slate-500"> | |
| <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-500 inline-block" /> Selected (≥50)</span> | |
| <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500 inline-block" /> Rejected</span> | |
| </div> | |
| </div> | |
| {/* Desktop table */} | |
| <div className="card overflow-hidden"> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full"> | |
| <thead> | |
| <tr className="border-b border-slate-800 bg-slate-900/80"> | |
| <th className="text-left px-5 py-3.5 text-xs font-semibold text-slate-500 uppercase tracking-wider w-12">Rank</th> | |
| <th className="text-left px-5 py-3.5 text-xs font-semibold text-slate-500 uppercase tracking-wider">Candidate</th> | |
| <th className="text-center px-4 py-3.5 text-xs font-semibold text-slate-500 uppercase tracking-wider">Score</th> | |
| <th className="text-left px-4 py-3.5 text-xs font-semibold text-slate-500 uppercase tracking-wider">Skills Match</th> | |
| <th className="text-center px-4 py-3.5 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden md:table-cell">Exp Fit</th> | |
| <th className="text-center px-4 py-3.5 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden lg:table-cell">Edu Fit</th> | |
| <th className="text-center px-4 py-3.5 text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th> | |
| <th className="px-4 py-3.5 w-10"></th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-slate-800"> | |
| {candidates.map((c) => { | |
| const isSelected = selectedId === c.rank; | |
| const totalReq = c.matched_skills.length + c.missing_skills.length; | |
| const skillPct = totalReq > 0 | |
| ? Math.round((c.matched_skills.length / totalReq) * 100) | |
| : 100; | |
| return ( | |
| <tr | |
| key={c.rank} | |
| id={`candidate-row-${c.rank}`} | |
| onClick={() => onSelectCandidate(c)} | |
| className={`cursor-pointer transition-all duration-150 ${ | |
| isSelected | |
| ? 'bg-primary-900/20 border-l-2 border-l-primary-500' | |
| : 'hover:bg-slate-800/40' | |
| }`} | |
| > | |
| {/* Rank */} | |
| <td className="px-5 py-4"> | |
| <div className="flex items-center justify-center"> | |
| <RankIcon rank={c.rank} /> | |
| </div> | |
| </td> | |
| {/* Candidate Info */} | |
| <td className="px-5 py-4"> | |
| <div className="font-semibold text-slate-100">{c.name}</div> | |
| <div className="text-xs text-slate-500 mt-0.5 truncate max-w-[180px]">{c.filename}</div> | |
| </td> | |
| {/* Score Ring */} | |
| <td className="px-4 py-4 text-center"> | |
| <div className="flex flex-col items-center gap-1"> | |
| <ScoreRing score={c.final_score} size={52} /> | |
| <span className="text-xs text-slate-500">{c.match_percent}%</span> | |
| </div> | |
| </td> | |
| {/* Skills Match */} | |
| <td className="px-4 py-4"> | |
| <div className="flex items-center gap-2 mb-1.5"> | |
| <div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden"> | |
| <div | |
| className="h-full rounded-full transition-all duration-700" | |
| style={{ | |
| width: `${skillPct}%`, | |
| backgroundColor: skillPct >= 70 ? '#10b981' : skillPct >= 40 ? '#f59e0b' : '#ef4444' | |
| }} | |
| /> | |
| </div> | |
| <span className="text-xs text-slate-400 w-8 text-right">{skillPct}%</span> | |
| </div> | |
| <div className="flex flex-wrap"> | |
| {c.matched_skills.slice(0, 3).map(s => ( | |
| <SkillBadge key={s} skill={s} variant="green" /> | |
| ))} | |
| {c.matched_skills.length > 3 && ( | |
| <span className="text-xs text-slate-500 self-center">+{c.matched_skills.length - 3}</span> | |
| )} | |
| {c.matched_skills.length === 0 && ( | |
| <span className="text-xs text-slate-600 italic">None matched</span> | |
| )} | |
| </div> | |
| </td> | |
| {/* Experience Fit */} | |
| <td className="px-4 py-4 text-center hidden md:table-cell"> | |
| <ScorePill score={c.experience_score} /> | |
| </td> | |
| {/* Education Fit */} | |
| <td className="px-4 py-4 text-center hidden lg:table-cell"> | |
| <div className="text-xs text-slate-400">{c.education_degree}</div> | |
| <ScorePill score={c.education_score} /> | |
| </td> | |
| {/* Status Badge */} | |
| <td className="px-4 py-4 text-center"> | |
| {c.status === 'Selected' ? ( | |
| <span className="badge-green flex items-center gap-1 justify-center whitespace-nowrap"> | |
| <CheckCircle size={12} /> Selected | |
| </span> | |
| ) : ( | |
| <span className="badge-red flex items-center gap-1 justify-center whitespace-nowrap"> | |
| <XCircle size={12} /> Rejected | |
| </span> | |
| )} | |
| </td> | |
| {/* Arrow */} | |
| <td className="px-4 py-4"> | |
| <ChevronRight size={16} className={`transition-colors ${isSelected ? 'text-primary-400' : 'text-slate-700'}`} /> | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function ScorePill({ score }) { | |
| const color = score >= 80 ? 'text-emerald-400' : score >= 50 ? 'text-amber-400' : 'text-red-400'; | |
| return <span className={`text-sm font-semibold ${color}`}>{score.toFixed(0)}</span>; | |
| } | |