Spaces:
Sleeping
Sleeping
| import React, { useEffect, useState, useMemo } from 'react'; | |
| import { supabase } from '../../supabaseClient'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| export default function TopPerformers() { | |
| // --- STATE --- | |
| const [candidates, setCandidates] = useState([]); | |
| const [loading, setLoading] = useState(true); | |
| const [showConfig, setShowConfig] = useState(false); | |
| // Config State | |
| const [config, setConfig] = useState({ | |
| skillsWeight: 5, | |
| experienceWeight: 5, | |
| projectWeight: 5, | |
| }); | |
| const handleConfigChange = (key, e) => { | |
| setConfig({ ...config, [key]: parseInt(e.target.value) }); | |
| }; | |
| // --- SUPABASE DATA FETCHING --- | |
| useEffect(() => { | |
| const fetchCandidates = async () => { | |
| try { | |
| // ✅ FIX: Removed comments inside the string | |
| const { data, error } = await supabase | |
| .from('applications') | |
| .select(` | |
| id, | |
| match_score, | |
| experience, | |
| profiles ( | |
| id, | |
| full_name, | |
| avatar_url, | |
| technical_skills, | |
| projects, | |
| experience_years, | |
| summary | |
| ), | |
| jobs ( title ) | |
| `) | |
| .limit(10); | |
| if (error) throw error; | |
| setCandidates(data || []); | |
| } catch (error) { | |
| console.error("Fetch error:", error); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| fetchCandidates(); | |
| }, []); | |
| // --- ⚡️ LOGIC ENGINE --- | |
| const processedCandidates = useMemo(() => { | |
| if (!candidates.length) return []; | |
| return candidates.map(candidate => { | |
| const profile = candidate.profiles || {}; | |
| const job = candidate.jobs || {}; | |
| // 1. EXPERIENCE - Try multiple sources | |
| const expVal = parseFloat(candidate.experience) || parseFloat(profile.experience_years) || 0; | |
| const expScore = Math.min(expVal * 10, 100); | |
| // 2. SKILLS - Use match_score with fallback to skill count | |
| let skillScore = parseFloat(candidate.match_score) || 0; | |
| if (skillScore === 0) { | |
| // Fallback: count technical skills | |
| const skillsText = profile.technical_skills || ""; | |
| const skillCount = typeof skillsText === 'string' && skillsText.trim().length > 0 | |
| ? skillsText.split(',').length | |
| : 0; | |
| skillScore = Math.min(skillCount * 10, 100); // Each skill = 10 points | |
| } | |
| // Display Logic Only | |
| const skillsText = profile.technical_skills || ""; | |
| const skillCountForDisplay = typeof skillsText === 'string' && skillsText.trim().length > 0 | |
| ? skillsText.split(',').length | |
| : 0; | |
| // 3. PROJECTS (JSON Array -> Number) | |
| const projectsData = profile.projects; | |
| const projCount = Array.isArray(projectsData) ? projectsData.length : 0; | |
| const projScore = Math.min(projCount * 20, 100); | |
| // 4. WEIGHTED FORMULA - BOOST MODEL | |
| // ✅ FIXED: Each weight acts as a boost multiplier | |
| // Weight of 5 = 1x, weight of 10 = 2x amplification | |
| // This ensures increasing a weight ALWAYS increases the score | |
| const skillBoost = skillScore * (1 + config.skillsWeight / 10); | |
| const expBoost = expScore * (1 + config.experienceWeight / 10); | |
| const projBoost = projScore * (1 + config.projectWeight / 10); | |
| const rawScore = Math.min((skillBoost + expBoost + projBoost) / 3, 100); | |
| return { | |
| ...candidate, | |
| id: candidate.id, | |
| name: profile.full_name || 'Candidate', | |
| avatar: profile.avatar_url, | |
| role: job.title || 'Applicant', | |
| summary: profile.summary, | |
| finalScore: Math.round(rawScore), | |
| displayExp: expVal, | |
| displayProj: projCount, | |
| displaySkills: skillCountForDisplay | |
| }; | |
| }) | |
| .sort((a, b) => b.finalScore - a.finalScore) | |
| .slice(0, 5); | |
| }, [candidates, config]); | |
| // --- STYLES --- | |
| const containerStyle = { | |
| backgroundColor: 'rgba(239, 68, 68, 0.05)', | |
| border: '1px solid rgba(239, 68, 68, 0.2)', | |
| borderRadius: '1rem', | |
| padding: '1.5rem', | |
| color: 'white', | |
| height: '100%', | |
| fontFamily: 'sans-serif' | |
| }; | |
| const configBoxStyle = { | |
| backgroundColor: 'rgba(0, 0, 0, 0.3)', | |
| border: '1px solid rgba(255, 255, 255, 0.1)', | |
| borderRadius: '0.75rem', | |
| padding: '1.25rem', | |
| marginBottom: '1.5rem', | |
| marginTop: '0.5rem' | |
| }; | |
| const labelRowStyle = { | |
| display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem', | |
| fontSize: '0.85rem', fontWeight: '500', color: '#D1D5DB' | |
| }; | |
| const getSliderStyle = (value, max) => { | |
| const percentage = (value / max) * 100; | |
| return { | |
| width: '100%', height: '6px', borderRadius: '3px', | |
| background: `linear-gradient(to right, #EF4444 0%, #EF4444 ${percentage}%, #374151 ${percentage}%, #374151 100%)`, | |
| appearance: 'none', outline: 'none', cursor: 'pointer' | |
| }; | |
| }; | |
| return ( | |
| <div style={containerStyle}> | |
| {/* HEADER */} | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}> | |
| <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>Top Performers</h2> | |
| <button | |
| onClick={() => setShowConfig(!showConfig)} | |
| style={{ | |
| background: 'none', border: 'none', | |
| color: showConfig ? '#EF4444' : '#9CA3AF', | |
| cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', | |
| fontSize: '0.9rem', fontWeight: '500' | |
| }} | |
| > | |
| <span style={{ fontSize: '1.1rem' }}>⚙</span> | |
| {showConfig ? "Hide" : "Config"} | |
| </button> | |
| </div> | |
| <p style={{ color: '#9CA3AF', fontSize: '0.85rem', marginBottom: '1.5rem', marginTop: '0.25rem' }}> | |
| Calculated based on Match Score, Experience & Projects | |
| </p> | |
| {/* CONFIG PANEL */} | |
| <AnimatePresence> | |
| {showConfig && ( | |
| <motion.div | |
| initial={{ height: 0, opacity: 0 }} | |
| animate={{ height: 'auto', opacity: 1 }} | |
| exit={{ height: 0, opacity: 0 }} | |
| style={{ overflow: 'hidden' }} | |
| > | |
| <div style={configBoxStyle}> | |
| <h3 style={{ margin: '0 0 1rem 0', fontSize: '0.9rem', color: '#F3F4F6' }}>Scoring Priorities</h3> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> | |
| <div> | |
| <div style={labelRowStyle}> | |
| <span>Skills Priority</span> <span style={{ color: '#EF4444' }}>{config.skillsWeight}</span> | |
| </div> | |
| <input type="range" max="10" value={config.skillsWeight} onChange={(e) => handleConfigChange('skillsWeight', e)} style={getSliderStyle(config.skillsWeight, 10)} className="custom-range" /> | |
| </div> | |
| <div> | |
| <div style={labelRowStyle}> | |
| <span>Experience Priority</span> <span style={{ color: '#EF4444' }}>{config.experienceWeight}</span> | |
| </div> | |
| <input type="range" max="10" value={config.experienceWeight} onChange={(e) => handleConfigChange('experienceWeight', e)} style={getSliderStyle(config.experienceWeight, 10)} className="custom-range" /> | |
| </div> | |
| <div> | |
| <div style={labelRowStyle}> | |
| <span>Projects Priority</span> <span style={{ color: '#EF4444' }}>{config.projectWeight}</span> | |
| </div> | |
| <input type="range" max="10" value={config.projectWeight} onChange={(e) => handleConfigChange('projectWeight', e)} style={getSliderStyle(config.projectWeight, 10)} className="custom-range" /> | |
| </div> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* CANDIDATES LIST */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> | |
| <AnimatePresence> | |
| {loading ? ( | |
| <p style={{ color: '#6B7280', textAlign: 'center' }}>Loading...</p> | |
| ) : processedCandidates.length > 0 ? ( | |
| processedCandidates.map((item) => { | |
| const exp = item.displayExp > 0 ? `${item.displayExp} yrs` : 'Fresher'; | |
| return ( | |
| <motion.div | |
| key={item.id} | |
| layout | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }} | |
| > | |
| <div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}> | |
| {/* Avatar */} | |
| {item.avatar ? ( | |
| <img src={item.avatar} alt={item.name} style={{ width: '45px', height: '45px', borderRadius: '50%', objectFit: 'cover', border: '2px solid #374151' }} /> | |
| ) : ( | |
| <div style={{ width: '45px', height: '45px', borderRadius: '50%', backgroundColor: '#374151', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 'bold' }}> | |
| {item.name.charAt(0)} | |
| </div> | |
| )} | |
| {/* Text Info */} | |
| <div> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> | |
| <span style={{ fontWeight: '600', fontSize: '1rem' }}>{item.name}</span> | |
| <span style={{ backgroundColor: 'rgba(255,255,255,0.1)', fontSize: '0.7rem', padding: '2px 6px', borderRadius: '4px', color: '#D1D5DB' }}> | |
| Score: {item.finalScore} | |
| </span> | |
| </div> | |
| <div style={{ fontSize: '0.75rem', color: '#9CA3AF', marginTop: '2px' }}> | |
| {item.role} • {exp} • <span style={{ color: '#FCA5A5' }}>{item.displayProj} Proj</span> • {item.displaySkills} Skills | |
| </div> | |
| </div> | |
| </div> | |
| </motion.div> | |
| ); | |
| }) | |
| ) : ( | |
| <p style={{ color: '#6B7280', textAlign: 'center' }}>No candidates found</p> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| <style>{` | |
| .custom-range::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 14px; height: 14px; | |
| border-radius: 50%; | |
| background: white; | |
| border: 2px solid #EF4444; | |
| cursor: pointer; | |
| margin-top: -4px; | |
| } | |
| `}</style> | |
| </div> | |
| ); | |
| } |