/* ============================================================ 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, suggested job titles and the attached job_posts (with department). It has no skills or currentCompany on list rows — those still come from the seed overlay. 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 { DEFAULT_PAGE_SIZE, PageSizeField } from '../ui/DataTable' import PageHeader from '../ui/PageHeader' 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 { exportStyledXlsx } from '../lib/exportXlsx' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' import * as pipelineApi from '../api/pipeline' import { avatarColor, initials as initialsOf } from '../data/seed' /** Backend GET /candidate/fetch caps `limit` at 100. */ const PAGE_SIZE_MAX = 100 const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'] /** "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 } /** * Distinct departments from the candidate's assigned + suggested job posts. * Seed templates also carry a department, but that is a prototype leftover and * must not drive the toolbar filter — it would never match /job/departments/fetch. */ function departmentsOf(row) { const seen = new Set() const out = [] const add = (value) => { const d = typeof value === 'string' ? value : '' if (!d || seen.has(d)) return seen.add(d) out.push(d) } add(row.assigned_job_post?.department) for (const jp of row.job_posts || []) add(jp.department) return out } /** * 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) || row.job_title || row.current_title const stage = pipelineApi.STAGE_FROM_STATUS[row.application_status] || template.stage const experience = years(row.experience) const departments = departmentsOf(row) 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, // Live job-post departments only. Seed department is left on `department` // for the seed-only profile modal, but the filter reads `departments`. departments, department: departments[0] || template.department, // Prefer real Form / platform tags from manual_upload; seed only as fallback. source: row.source || template.source, // 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 [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE) 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: pageSize }), queryFn: () => candidatesApi.list({ limit: pageSize }), }) const deptsQuery = useQuery({ queryKey: qk.jobPosts.departments(), queryFn: async () => { const res = await jobPostsApi.listDepartments() return Array.isArray(res?.data) ? res.data : [] }, }) const departments = deptsQuery.data ?? [] 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.departments || []).includes(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') } /* CSV of the FILTERED grid, built client-side — there is no /candidate export endpoint (jobs and reports each own theirs). Exporting `list` rather than `pool` means the file always matches what the recruiter is looking at, search and department filter included. Company/skills are seed-overlay values, same as the cards render. */ async function exportCsv() { if (!list.length) { toast('Nothing to export — current filters match no candidates', 'warning') return } try { await exportStyledXlsx({ filename: `talent-pool-${new Date().toISOString().slice(0, 10)}`, title: 'Talent Pool', subtitle: `${list.length} candidate${list.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`, columns: [ { header: 'Name', key: 'name', width: 24 }, { header: 'Email', key: 'email', width: 28 }, { header: 'Current Title', key: 'title', width: 24 }, { header: 'Company', key: 'company', width: 20 }, { header: 'Departments', key: 'departments', width: 22 }, { header: 'Stage', key: 'stage', width: 13 }, { header: 'Experience (yrs)', key: 'experience', width: 14 }, { header: 'Source', key: 'source', width: 16 }, { header: 'AI Score', key: 'aiScore', width: 10 }, { header: 'Skills', key: 'skills', width: 40 }, ], rows: list.map((c) => ({ name: c.name, email: c.email, title: c.currentTitle, company: c.currentCompany, departments: (c.departments || []).join('; '), stage: c.stage, experience: c.experience, source: c.source, aiScore: c.aiScore ?? '', skills: (c.skills || []).join('; '), })), }) toast(`Exported ${list.length} candidate${list.length === 1 ? '' : 's'}`, 'success') } catch { toast('Export failed', 'error') } } return (
{pool.length} silver-medalists & passive candidates to re-engage} actions={ } />
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) }} /> )}
) }