/* ============================================================ Talent Pool — the prototype's card grid, now fed by GET /candidate/fetch. The layout, the toolbar, the card and the 8-tab profile modal are the originals, unchanged. Only the data source moved. The endpoint returns name, email, experience, application_status and the suggested job title. It has no aiScore, skills, currentCompany, source or department — the agent writes a verdict and prose, not a score, and no résumé-derived skills are persisted. Each record is therefore OVERLAID on a seed candidate: real values win, seed fills the rest, so the card renders exactly as it always did. Clicking a card opens CandidateProfile in place. It used to deep-link into /candidates, which stopped resolving once the ids became real user_ids. The card is seed-overlaid, but the MODAL is not: it re-reads the candidate by `userId` through GET /candidate/fetch?user_id=, which is a far richer payload than the list rows — résumé text, the agent's verdict, documents, and the interviews / notes / activity / feedback collections, all writable from their own tabs. That switch happens inside CandidateProfile; passing `userId` is the whole trigger. A SECOND read fires on that same click: GET /pipeline/candidate/score/fetch, the pipeline board's score endpoint, which returns the candidate's current ats_results row. The two run concurrently — this screen owns the score call, CandidateProfile owns the detail call — and the score wins over both the list row's denormalised ai_score and the seed placeholder. It is deliberately NOT fetched for the grid: 100 cards would be 100 requests, and the card only ever needed a number good enough to sort by eye. ============================================================ */ import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { useToast } from '../ui/Toast' import CandidateProfile from './CandidateProfile' import { AtsMatch } from './Candidates' import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as pipelineApi from '../api/pipeline' import { avatarColor, departments, initials as initialsOf } from '../data/seed' /** The seed bucket holds 100 candidates; one template per person, no reuse. */ const FETCH_LIMIT = 100 const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] /** * Candidate_application_Status (backend/inbox/enums.py) -> the seed stage * vocabulary every screen renders. CLOSED is the column default, i.e. untriaged, * so it reads as Shortlist rather than as an outcome. */ const STAGE_FROM_STATUS = { PENDING: 'Shortlist', CLOSED: 'Shortlist', PROCESS: 'Screening', ONHOLD: 'Screening', APPROVED: 'Hired', REJECTED: 'Rejected', } /** "6 years" -> 6. The column is free text, so anything unparseable defers to seed. */ function years(value) { const n = parseInt(value, 10) return Number.isFinite(n) ? n : null } /** * One API record overlaid on one seed candidate. * * `id` deliberately stays the SEED id: the favourite/advance seed mutations key * off it, so a UUID here would silently drop those writes. The real identifier * rides along on `userId`, and that is what the profile modal reads its live * record with. */ function merge(row, template) { const name = row.name || template.name const title = (row.job_posts || []).map((j) => j.title).find(Boolean) const stage = STAGE_FROM_STATUS[row.application_status] || template.stage const experience = years(row.experience) return { ...template, userId: row.user_id, name, initials: initialsOf(name), color: avatarColor(name), email: row.email || template.email, experience: experience ?? template.experience, stage, status: stage, currentTitle: title || template.currentTitle, jobTitle: title || template.jobTitle, // NO seed fallback. `ai_score` is the candidate's current ats_results row, // resolved server-side; null means the scoring engine never scored this // person, and the card renders nothing rather than a plausible fake number // a recruiter would read as a real match. aiScore: row.ai_score ?? null, recommendation: row.recommendation ?? null, } } /** * `inbox` holds one row per (user, message), so a candidate who mailed us three * times arrives three times. Collapse onto the person before pairing templates, * otherwise one candidate would occupy three cards and three seed identities. */ function buildPool(rows, templates) { if (!templates.length) return [] const byPerson = new Map() for (const row of rows) { const key = row.user_id ?? `inbox-${row.inbox_id}` if (!byPerson.has(key)) byPerson.set(key, row) } return [...byPerson.values()].map((row, i) => merge(row, templates[i % templates.length])) } export default function TalentPool() { const { toast } = useToast() const { data: templates = [] } = useQuery(seedQuery('candidates')) const updateCandidates = useSeedMutation('candidates') const [q, setQ] = useState('') const [dept, setDept] = useState('') const [profileFor, setProfileFor] = useState(null) const [atsFor, setAtsFor] = useState(null) const navigate = useNavigate() // Real candidates get the full profile PAGE; the in-place modal remains only // for seed cards that have no user account to deep-link. const openProfile = (c) => { if (c.userId) navigate(`/candidate/${c.userId}`) else setProfileFor(c) } const query = useQuery({ queryKey: qk.candidates.list({ limit: FETCH_LIMIT }), queryFn: () => candidatesApi.list({ limit: FETCH_LIMIT }), }) const pool = useMemo( () => buildPool(candidatesApi.toRows(query.data), templates), [query.data, templates], ) /** * The clicked candidate's current ATS score, from the pipeline board's own * endpoint. `enabled` is the "only on click" rule: with no open profile there * is no userId, and the query never runs. React Query caches it per user, so * re-opening the same card repaints from cache. * * Sent WITHOUT job_post_id on purpose. The pool is a cross-job view — it has * no job filter and most rows carry no assigned post — so pinning would only * ever hide a score that exists under some other post. Unpinned, the endpoint * answers with the newest current score the candidate has anywhere. */ const scoreQuery = useQuery({ queryKey: qk.pipeline.candidateScore({ userId: profileFor?.userId ?? null }), queryFn: () => pipelineApi.fetchCandidateScore({ userId: profileFor.userId }), select: pipelineApi.toAtsScore, enabled: Boolean(profileFor?.userId), }) // A candidate with no ats_results row answers `null`, which must read as "no // live score" and leave the existing value alone — not as a score of zero. const atsScore = scoreQuery.data?.overall_score ?? null const list = useMemo( () => pool.filter((c) => { if (dept && c.department !== dept) return false if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false return true }), [pool, q, dept], ) // Both mirror Candidates.jsx so a change made here shows up there too. The // card renders neither favourite nor stage, so only the open modal restates. // Favourite is a real PATCH once the modal holds a userId; this seed path is // the fallback for inbox rows that were never linked to a user. function toggleFav(c) { updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x))) setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p)) toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success') } function advance(c) { const i = STAGE_ORDER.indexOf(c.stage) if (i === -1 || i >= STAGE_ORDER.length - 1) { toast(`${c.name} cannot be advanced further`, 'warning') return } const stage = STAGE_ORDER[i + 1] updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x))) setProfileFor((p) => (p && p.id === c.id ? { ...p, stage, status: stage } : p)) toast(`${c.name} moved to ${stage}`, 'success') } return (

Talent Pool

{pool.length} silver-medalists & passive candidates to re-engage

setQ(e.target.value)} placeholder="Search by name, skill, company…" />
{list.length === 0 ? (
{/* Same slot, same component — a failed fetch must not read as "no results". */} {query.isError ? ( {friendlyAuthError(query.error, 'Please try again.')} ) : query.isPending ? ( Fetching candidates. ) : ( Try a different search or department. )}
) : ( list.map((c) => (
openProfile(c)} >
{c.name}
{c.currentTitle}
{c.aiScore != null && }
{c.skills.slice(0, 4).map((s) => {s})}
{c.experience} yrs {c.currentCompany} {c.source}
)) )}
{atsFor && ( setAtsFor(null)} onProfile={(c) => { setAtsFor(null); openProfile(c) }} /> )} {profileFor && ( setProfileFor(null)} onAdvance={advance} onToggleFav={toggleFav} onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }} /> )}
) }