HR-ATS-Portal/frontend/src/screens/TalentPool.jsx

298 lines
12 KiB
JavaScript

/* ============================================================
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 (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Talent Pool</h1>
<p className="page-sub">{pool.length} silver-medalists &amp; passive candidates to re-engage</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => toast('Talent campaign created', 'success')}>
<Icon name="send" /> Start Campaign
</button>
</div>
</div>
<div className="card mb-18">
<div className="card-body" style={{ padding: 16 }}>
<div className="toolbar" style={{ marginBottom: 0 }}>
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
</div>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)}
</select>
</div>
</div>
</div>
<div className="grid g-3">
{list.length === 0 ? (
<div style={{ gridColumn: '1/-1' }}>
{/* Same slot, same component — a failed fetch must not read as "no results". */}
{query.isError ? (
<EmptyState title="Could not load talent pool">
{friendlyAuthError(query.error, 'Please try again.')}
</EmptyState>
) : query.isPending ? (
<EmptyState title="Loading talent pool…">Fetching candidates.</EmptyState>
) : (
<EmptyState title="No talent found">Try a different search or department.</EmptyState>
)}
</div>
) : (
list.map((c) => (
<div
key={c.id}
className="card"
style={{ cursor: 'pointer' }}
onClick={() => openProfile(c)}
>
<div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="lr-title">{c.name}</div>
<div className="lr-sub">{c.currentTitle}</div>
</div>
{c.aiScore != null && <ScoreChip score={c.aiScore} />}
</div>
<div className="k-tags" style={{ marginBottom: 12 }}>
{c.skills.slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)}
</div>
<div className="divider" style={{ margin: '12px 0' }} />
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
<span className="cell-sub"><Icon name="briefcase" /> {c.experience} yrs</span>
<span className="cell-sub">{c.currentCompany}</span>
<Badge className="b-gray">{c.source}</Badge>
</div>
</div>
</div>
))
)}
</div>
{atsFor && (
<AtsMatch
candidate={atsFor}
onClose={() => setAtsFor(null)}
onProfile={(c) => { setAtsFor(null); openProfile(c) }}
/>
)}
{profileFor && (
<CandidateProfile
candidate={profileFor}
atsScore={atsScore}
recommendation={scoreQuery.data?.band ?? null}
onClose={() => setProfileFor(null)}
onAdvance={advance}
onToggleFav={toggleFav}
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
/>
)}
</div>
)
}