254 lines
10 KiB
JavaScript
254 lines
10 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.
|
|
============================================================ */
|
|
|
|
import { useMemo, useState } from 'react'
|
|
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 { 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 = ['Applied', '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 Applied rather than as an outcome.
|
|
*/
|
|
const STAGE_FROM_STATUS = {
|
|
PENDING: 'Applied', CLOSED: 'Applied', 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,
|
|
// Real ATS score (scoring engine, joined server-side by inbox message)
|
|
// wins over the seed placeholder; recommendation follows it.
|
|
aiScore: row.ai_score ?? template.aiScore,
|
|
recommendation: row.recommendation ?? template.recommendation,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `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 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],
|
|
)
|
|
|
|
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 & 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={() => setProfileFor(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>
|
|
<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); setProfileFor(c) }}
|
|
/>
|
|
)}
|
|
|
|
{profileFor && (
|
|
<CandidateProfile
|
|
candidate={profileFor}
|
|
onClose={() => setProfileFor(null)}
|
|
onAdvance={advance}
|
|
onToggleFav={toggleFav}
|
|
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|