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

375 lines
15 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, 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 (
<div className="page">
<PageHeader
title="Talent Pool"
sub={<>{pool.length} silver-medalists &amp; passive candidates to re-engage</>}
actions={
<button className="btn btn-primary" onClick={() => toast('Talent campaign created', 'success')}>
<Icon name="send" /> Start Campaign
</button>
}
/>
<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} value={d}>{d}</option>)}
</select>
<button className="btn btn-secondary" onClick={exportCsv}>
<Icon name="download" /> Export
</button>
<PageSizeField
value={pageSize}
onChange={setPageSize}
max={PAGE_SIZE_MAX}
label="Show"
/>
</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>
)
}