iris_backend / src /components /Admin /TalentClusters.jsx
Muhammed Sameer
Safely configure API URLs for production fallback
d998071
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { supabase } from '../../supabaseClient';
import FullProfileOverlay from '../FullProfileOverlay';
// ─── Icons ───────────────────────────────────────────────────────────────────
const ClusterIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" /><circle cx="4" cy="6" r="3" /><circle cx="20" cy="6" r="3" />
<circle cx="4" cy="18" r="3" /><circle cx="20" cy="18" r="3" />
<line x1="12" y1="9" x2="4" y2="7" /><line x1="12" y1="9" x2="20" y2="7" />
<line x1="12" y1="15" x2="4" y2="17" /><line x1="12" y1="15" x2="20" y2="17" />
</svg>
);
const UsersIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
const SearchIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const ChevronDown = ({ open }) => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.3s ease' }}>
<polyline points="6 9 12 15 18 9" />
</svg>
);
const XIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
// ─── Cluster colour palette ───────────────────────────────────────────────────
const CLUSTER_COLORS = [
{ accent: '#EF4444', glow: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.3)' },
{ accent: '#8B5CF6', glow: 'rgba(139,92,246,0.15)', border: 'rgba(139,92,246,0.3)' },
{ accent: '#06B6D4', glow: 'rgba(6,182,212,0.15)', border: 'rgba(6,182,212,0.3)' },
{ accent: '#10B981', glow: 'rgba(16,185,129,0.15)', border: 'rgba(16,185,129,0.3)' },
{ accent: '#F59E0B', glow: 'rgba(245,158,11,0.15)', border: 'rgba(245,158,11,0.3)' },
{ accent: '#EC4899', glow: 'rgba(236,72,153,0.15)', border: 'rgba(236,72,153,0.3)' },
];
const getColor = (idx) => CLUSTER_COLORS[idx % CLUSTER_COLORS.length];
// ─── Profile Card ─────────────────────────────────────────────────────────────
const ProfileCard = ({ profile, accent, onView }) => {
const [hovered, setHovered] = useState(false);
const skills = Array.isArray(profile.technical_skills)
? profile.technical_skills.slice(0, 4)
: typeof profile.technical_skills === 'string'
? profile.technical_skills.split(',').slice(0, 4).map(s => s.trim())
: [];
return (
<motion.div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
whileHover={{ y: -4, scale: 1.01 }}
onClick={() => onView(profile)}
style={{
backgroundColor: hovered ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${hovered ? accent : 'rgba(255,255,255,0.08)'}`,
borderRadius: '12px',
padding: '1rem',
cursor: 'pointer',
transition: 'border-color 0.2s',
boxShadow: hovered ? `0 4px 20px ${accent}30` : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.6rem' }}>
<img
src={profile.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(profile.full_name || 'User')}&background=random&size=48`}
alt={profile.full_name}
style={{ width: 40, height: 40, borderRadius: '50%', objectFit: 'cover', border: `2px solid ${accent}55` }}
/>
<div>
<p style={{ fontWeight: '700', color: '#fff', fontSize: '0.9rem', marginBottom: 2 }}>{profile.full_name || 'Unknown'}</p>
<p style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{profile.headline || profile.role || 'β€”'}</p>
</div>
</div>
<p style={{ fontSize: '0.75rem', color: '#64748b', marginBottom: '0.5rem' }}>
{profile.experience_years ? `${profile.experience_years} yrs exp` : 'No experience listed'}
</p>
{skills.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
{skills.map((s, i) => (
<span key={i} style={{
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
backgroundColor: `${accent}20`, color: accent,
border: `1px solid ${accent}40`
}}>{s}</span>
))}
</div>
)}
</motion.div>
);
};
// ─── Cluster Card ─────────────────────────────────────────────────────────────
const ClusterCard = ({ label, profiles, colorIdx, searchQuery, onViewProfile }) => {
const [expanded, setExpanded] = useState(true);
const color = getColor(colorIdx);
const filtered = profiles.filter(p => {
const q = searchQuery.toLowerCase();
return (
(p.full_name || '').toLowerCase().includes(q) ||
(p.headline || '').toLowerCase().includes(q) ||
(p.role || '').toLowerCase().includes(q)
);
});
if (searchQuery && filtered.length === 0) return null;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
style={{
backgroundColor: color.glow,
border: `1px solid ${color.border}`,
borderRadius: '16px',
overflow: 'hidden',
marginBottom: '1.5rem',
}}
>
{/* Header */}
<button
onClick={() => setExpanded(e => !e)}
style={{
width: '100%', background: 'none', border: 'none', cursor: 'pointer',
padding: '1.25rem 1.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
color: '#fff',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
width: 36, height: 36, borderRadius: '10px',
backgroundColor: `${color.accent}22`, display: 'flex', alignItems: 'center', justifyContent: 'center',
border: `1px solid ${color.accent}55`
}}>
<ClusterIcon style={{ color: color.accent }} />
</div>
<div style={{ textAlign: 'left' }}>
<h3 style={{ fontSize: '1.05rem', fontWeight: '700', color: '#fff', margin: 0 }}>{label}</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: '#94a3b8', fontSize: '0.8rem', marginTop: 2 }}>
<UsersIcon />
<span>{filtered.length} {filtered.length === 1 ? 'profile' : 'profiles'}</span>
</div>
</div>
</div>
<div style={{ color: color.accent }}>
<ChevronDown open={expanded} />
</div>
</button>
{/* Body */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
key="body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
style={{ overflow: 'hidden' }}
>
<div style={{
padding: '0 1.5rem 1.5rem',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: '0.75rem'
}}>
{filtered.map(p => (
<ProfileCard
key={p.id}
profile={p}
accent={color.accent}
onView={onViewProfile}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
// ─── Profile Detail Modal ─────────────────────────────────────────────────────
const ProfileModal = ({ profile, onClose }) => {
if (!profile) return null;
const skills = Array.isArray(profile.technical_skills)
? profile.technical_skills
: typeof profile.technical_skills === 'string'
? profile.technical_skills.split(',').map(s => s.trim())
: [];
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
style={{
position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(6px)', zIndex: 100, display: 'flex',
alignItems: 'center', justifyContent: 'center', padding: '1rem'
}}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={e => e.stopPropagation()}
style={{
backgroundColor: '#0f172a',
backgroundImage: `
radial-gradient(at 0% 0%, rgba(139,92,246,0.2) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(239,68,68,0.2) 0px, transparent 50%)
`,
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '20px',
width: '100%', maxWidth: '540px',
maxHeight: '80vh', overflowY: 'auto',
boxShadow: '0 25px 50px rgba(0,0,0,0.5)',
padding: '2rem',
}}
>
{/* Close */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<img
src={profile.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(profile.full_name || 'User')}&background=random&size=80`}
alt={profile.full_name}
style={{ width: 56, height: 56, borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(239,68,68,0.4)' }}
/>
<div>
<h2 style={{ fontSize: '1.4rem', fontWeight: '800', color: '#fff', margin: 0 }}>{profile.full_name}</h2>
<p style={{ color: '#94a3b8', fontSize: '0.85rem', margin: 0 }}>{profile.headline || profile.role || 'β€”'}</p>
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748b', cursor: 'pointer' }}>
<XIcon />
</button>
</div>
{/* Stats Row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem', marginBottom: '1.5rem' }}>
{[
{ label: 'Experience', value: profile.experience_years ? `${profile.experience_years} yrs` : 'β€”' },
{ label: 'Cluster', value: profile.cluster_label || 'β€”' },
{ label: 'Email', value: profile.email ? profile.email.split('@')[0] : 'β€”' },
].map(({ label, value }) => (
<div key={label} style={{
backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: '10px',
padding: '0.75rem', border: '1px solid rgba(255,255,255,0.08)'
}}>
<p style={{ fontSize: '0.7rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{label}</p>
<p style={{ fontSize: '0.85rem', fontWeight: '600', color: '#e2e8f0', wordBreak: 'break-all' }}>{value}</p>
</div>
))}
</div>
{/* Summary */}
{profile.summary && (
<div style={{ marginBottom: '1.5rem' }}>
<h4 style={{ fontSize: '0.85rem', color: '#94a3b8', fontWeight: '600', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Summary</h4>
<p style={{ fontSize: '0.9rem', lineHeight: '1.6', color: '#cbd5e1', backgroundColor: 'rgba(255,255,255,0.04)', padding: '0.75rem', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)' }}>
{profile.summary}
</p>
</div>
)}
{/* Skills */}
{skills.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h4 style={{ fontSize: '0.85rem', color: '#94a3b8', fontWeight: '600', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Technical Skills</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{skills.map((s, i) => (
<span key={i} style={{
fontSize: '0.8rem', padding: '4px 10px', borderRadius: '6px',
backgroundColor: 'rgba(239,68,68,0.1)', color: '#EF4444',
border: '1px solid rgba(239,68,68,0.2)'
}}>{s}</span>
))}
</div>
</div>
)}
{/* Education */}
{profile.education && (
<div>
<h4 style={{ fontSize: '0.85rem', color: '#94a3b8', fontWeight: '600', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Education</h4>
<p style={{ fontSize: '0.85rem', color: '#cbd5e1' }}>
{typeof profile.education === 'string' ? profile.education : JSON.stringify(profile.education)}
</p>
</div>
)}
</motion.div>
</motion.div>
</AnimatePresence>
);
};
// ─── MAIN PAGE ────────────────────────────────────────────────────────────────
export default function TalentClusters() {
const [clusters, setClusters] = useState({}); // { labelName: [profiles] }
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedProfile, setSelectedProfile] = useState(null);
const [error, setError] = useState(null);
const [isClusteringRunning, setIsClusteringRunning] = useState(false);
useEffect(() => {
fetchClusters();
}, []);
const fetchClusters = async () => {
setIsLoading(true);
setError(null);
try {
const { data, error } = await supabase
.from('profiles')
.select('id, full_name, email, avatar_url, headline, role, experience_years, technical_skills, summary, education, cluster_label')
.not('cluster_label', 'is', null);
if (error) throw error;
// Group by cluster_label
const grouped = {};
data.forEach(profile => {
const label = profile.cluster_label || 'Uncategorized';
if (!grouped[label]) grouped[label] = [];
grouped[label].push(profile);
});
setClusters(grouped);
} catch (err) {
console.error('Failed to fetch clusters:', err);
setError('Failed to load talent clusters. Please try again.');
} finally {
setIsLoading(false);
}
};
const runClustering = async () => {
setIsClusteringRunning(true);
try {
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const response = await fetch(`${API_URL}/run-clustering`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error('Failed to start clustering');
}
const data = await response.json();
console.log('βœ… Clustering started:', data);
alert('Clustering pipeline started! This may take a few minutes. You will be notified when complete.');
// Poll for completion (check every 5 seconds for up to 5 minutes)
const pollInterval = setInterval(async () => {
try {
await fetchClusters();
// If we get here and setClusters worked, clustering likely complete
// Check if there are actually clusters now
const { data: profiles } = await supabase
.from('profiles')
.select('cluster_label')
.not('cluster_label', 'is', null)
.limit(1);
if (profiles && profiles.length > 0) {
clearInterval(pollInterval);
setIsClusteringRunning(false);
alert('βœ… Clustering complete! Clusters loaded.');
}
} catch (err) {
console.log('Still clustering...');
}
}, 5000);
// Clear interval after 5 minutes
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000);
} catch (err) {
console.error('Error starting clustering:', err);
alert('Failed to start clustering. Check the backend logs.');
setIsClusteringRunning(false);
}
};
const clusterEntries = Object.entries(clusters).sort((a, b) => b[1].length - a[1].length);
const totalProfiles = Object.values(clusters).reduce((s, arr) => s + arr.length, 0);
return (
<div style={{ paddingBottom: '4rem' }}>
<style>{`
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
@keyframes spin { 100% { transform: rotate(360deg); } }
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
`}</style>
{/* Header */}
<header style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<div style={{ color: '#EF4444' }}><ClusterIcon /></div>
<h1 style={{ fontSize: '1.875rem', fontWeight: 'bold', margin: 0 }}>Talent Clusters</h1>
</div>
<p style={{ color: '#64748b', fontSize: '0.9rem' }}>
AI-grouped candidate profiles based on skills and experience similarity.
</p>
</header>
{/* Stats Bar */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
{[
{ label: 'Total Clusters', value: clusterEntries.length, color: '#EF4444' },
{ label: 'Total Profiles', value: totalProfiles, color: '#8B5CF6' },
{ label: 'Avg. Cluster Size', value: clusterEntries.length ? Math.round(totalProfiles / clusterEntries.length) : 0, color: '#06B6D4' },
].map(({ label, value, color }) => (
<div key={label} style={{
flex: 1, minWidth: 140,
backgroundColor: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '12px', padding: '1rem 1.25rem',
}}>
<p style={{ fontSize: '0.75rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{label}</p>
<p style={{ fontSize: '1.8rem', fontWeight: '800', color, margin: 0, lineHeight: 1 }}>{isLoading ? 'β€”' : value}</p>
</div>
))}
</div>
{/* Search + Refresh */}
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '2rem', alignItems: 'center' }}>
<div style={{ position: 'relative', flexGrow: 1 }}>
<div style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#64748b' }}>
<SearchIcon />
</div>
<input
type="text"
placeholder="Search by name, role, or headline..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', padding: '0.75rem 0.75rem 0.75rem 2.25rem',
borderRadius: '0.5rem', border: '1px solid rgba(239,68,68,0.3)',
backgroundColor: 'rgba(255,255,255,0.04)', color: 'white',
fontSize: '0.9rem', outline: 'none', boxSizing: 'border-box'
}}
/>
</div>
<motion.button
onClick={runClustering}
disabled={isClusteringRunning}
whileHover={!isClusteringRunning ? { scale: 1.04 } : {}}
whileTap={!isClusteringRunning ? { scale: 0.96 } : {}}
style={{
backgroundColor: isClusteringRunning ? 'rgba(139,92,246,0.15)' : 'rgba(139,92,246,0.15)',
border: '1px solid rgba(139,92,246,0.4)',
color: isClusteringRunning ? '#94a3b8' : '#8B5CF6',
padding: '0.75rem 1.25rem',
borderRadius: '0.5rem',
cursor: isClusteringRunning ? 'not-allowed' : 'pointer',
fontWeight: '600',
fontSize: '0.85rem',
whiteSpace: 'nowrap',
opacity: isClusteringRunning ? 0.6 : 1,
transition: 'all 0.2s ease'
}}
>
{isClusteringRunning ? '⟳ Clustering...' : '⚑ Run Clustering'}
</motion.button>
</div>
{/* Content */}
{isLoading ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '300px', gap: '1rem' }}>
<div style={{
width: 40, height: 40, border: '3px solid rgba(239,68,68,0.2)',
borderTopColor: '#EF4444', borderRadius: '50%',
animation: 'spin 0.8s linear infinite'
}} />
<p style={{ color: '#64748b' }}>Loading talent clusters…</p>
</div>
) : error ? (
<div style={{ textAlign: 'center', padding: '3rem', color: '#EF4444' }}>
<p>{error}</p>
<button onClick={fetchClusters} style={{ marginTop: '1rem', backgroundColor: '#EF4444', color: 'white', border: 'none', padding: '0.5rem 1.5rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600' }}>
Retry
</button>
</div>
) : clusterEntries.length === 0 ? (
<div style={{ textAlign: 'center', padding: '4rem', color: '#64748b' }}>
<ClusterIcon />
<p style={{ marginTop: '1rem' }}>No clusters found. Run the clustering pipeline first.</p>
</div>
) : (
<>
{/* Cluster grid legend */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1.5rem' }}>
{clusterEntries.map(([label, profiles], idx) => {
const color = getColor(idx);
return (
<span key={label} style={{
fontSize: '0.78rem', padding: '4px 12px', borderRadius: '99px',
backgroundColor: `${color.accent}18`, color: color.accent,
border: `1px solid ${color.accent}44`, fontWeight: '600'
}}>
{label} ({profiles.length})
</span>
);
})}
</div>
{/* Cluster cards */}
{clusterEntries.map(([label, profiles], idx) => (
<ClusterCard
key={label}
label={label}
profiles={profiles}
colorIdx={idx}
searchQuery={searchQuery}
onViewProfile={setSelectedProfile}
/>
))}
</>
)}
{/* Profile modal */}
<AnimatePresence>
{selectedProfile && (
<ProfileModal profile={selectedProfile} onClose={() => setSelectedProfile(null)} />
)}
</AnimatePresence>
</div>
);
}