366 lines
15 KiB
JavaScript
366 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 { 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. */
|
|
function exportCsv() {
|
|
if (!list.length) {
|
|
toast('Nothing to export — current filters match no candidates', 'warning')
|
|
return
|
|
}
|
|
const esc = (v) => {
|
|
const s = v == null ? '' : String(v)
|
|
return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
|
|
}
|
|
const header = ['Name', 'Email', 'Current Title', 'Company', 'Departments', 'Stage', 'Experience (yrs)', 'Source', 'AI Score', 'Skills']
|
|
const lines = list.map((c) => [
|
|
c.name, c.email, c.currentTitle, c.currentCompany,
|
|
(c.departments || []).join('; '), c.stage, c.experience,
|
|
c.source, c.aiScore ?? '', (c.skills || []).join('; '),
|
|
].map(esc).join(','))
|
|
const blob = new Blob([[header.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `talent-pool-${new Date().toISOString().slice(0, 10)}.csv`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
a.remove()
|
|
URL.revokeObjectURL(url)
|
|
toast(`Exported ${list.length} candidate${list.length === 1 ? '' : 's'} to CSV`, 'success')
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<PageHeader
|
|
title="Talent Pool"
|
|
sub={<>{pool.length} silver-medalists & 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>
|
|
)
|
|
}
|