Phase 2: wire frontend candidate screens to the real scoring API

Backend: GET /job/fetch (active job posts, job-board OR candidate viewers) and
/candidate/fetch now works unscoped for the cross-job pool. Frontend: apiClient
gains FormData support; new api/candidates.js with a shared snake->camel view
mapper; CvImport is a real upload->score flow (job selector, PDF multipart to
/candidate/score, per-file results, no more simulation); TalentPool and
Candidates render the persisted pool with job/skill/source/ATS filters; the ATS
modal shows the real critique and matched/missing skills; CandidateProfile
keeps only tabs the backend can back. Screens hide affordances with no backing
column instead of rendering placeholders (Inbox precedent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dashboard_Wiring
Talha Ahmed 2026-08-10 22:27:20 +05:00
parent a68d4bc2f6
commit 9ffa1ef673
10 changed files with 686 additions and 927 deletions

View File

@ -6,6 +6,8 @@ from job.candidate.views import CandidateScoring,FileRead
from sqlalchemy.ext.asyncio import AsyncSession
from users.permissions import PermissionTag, require_permission
from job.job_post.views import JobPost,JobPostCreate
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
import logging
from job.job_post.plugins import PlatformAlias
from fastapi import UploadFile, File, Form
@ -146,11 +148,12 @@ async def score_inbox_candidates(
@router.get("/candidate/fetch")
async def fetch_candidates(
job_id: str = Query(...),
job_id: str = Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Persisted leaderboard for a job: completed by score desc, failures last."""
"""Persisted leaderboard: completed by score desc, failures last. Without job_id
returns the whole pool across jobs."""
try:
service=CandidateScoring(session=session)
data=await service.fetch_candidates(job_id)
@ -161,6 +164,27 @@ async def fetch_candidates(
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/fetch")
async def fetch_jobs(
current_user: dict = Depends(
require_permission(
PermissionTag.JOB_BOARD_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False
)
),
session: AsyncSession = Depends(get_session),
):
"""Active job posts, for pickers and boards. Either job-board or candidate viewers
may list them recruiters scoring CVs need a job to score against."""
try:
rows=await JobPosts.get_active_job_posts(session)
data=[serialize_job_post(row) for row in rows]
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch_by_id")
async def fetch_candidate_by_id(
candidate_id: str = Query(...),

View File

@ -67,20 +67,24 @@ class Candidates(SQLModel, table=True):
return result.scalars().first()
@classmethod
async def get_candidates_by_job(cls, session: AsyncSession, job_id: str):
"""Leaderboard order: completed by score desc, failures last, ties stable."""
uid = cls._as_uuid(job_id)
if uid is None:
return []
result = await session.execute(
select(cls)
.where(cls.job_id == uid)
.order_by(
cls.status.asc(), # "completed" < "failed"
cls.match_score.desc().nulls_last(),
cls.created_at.asc(),
)
async def get_candidates_by_job(cls, session: AsyncSession, job_id: str | None = None):
"""Leaderboard order: completed by score desc, failures last, ties stable.
job_id=None returns the whole pool across jobs (same ordering) for the
frontend's unscoped Candidates/Talent Pool views.
"""
statement = select(cls)
if job_id is not None:
uid = cls._as_uuid(job_id)
if uid is None:
return []
statement = statement.where(cls.job_id == uid)
statement = statement.order_by(
cls.status.asc(), # "completed" < "failed"
cls.match_score.desc().nulls_last(),
cls.created_at.asc(),
)
result = await session.execute(statement)
return result.scalars().all()
@classmethod

View File

@ -160,10 +160,12 @@ class CandidateScoring:
raise HTTPException(status_code=400,detail="No attachments found for the given message(s)")
return await self._score_and_persist(job_id,sources,"inbox",current_user)
async def fetch_candidates(self,job_id):
job=await JobPosts.get_job_post_by_id(self.session,job_id)
if job is None or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
async def fetch_candidates(self,job_id=None):
# job_id omitted -> the whole pool across jobs (frontend Candidates/TalentPool).
if job_id is not None:
job=await JobPosts.get_job_post_by_id(self.session,job_id)
if job is None or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
rows=await Candidates.get_candidates_by_job(self.session,job_id)
return [serialize_candidate(row) for row in rows]

View File

@ -0,0 +1,82 @@
/* ============================================================
candidates.js ATS scoring endpoints (backend/job/app.py).
Same conventions as inbox.js: one named export per endpoint, no hooks,
camelCase params mapped to snake_case at the call boundary, and every
function returns the parsed {data, total, status_code} envelope.
============================================================ */
import { request } from '../lib/apiClient'
/** Active job posts for pickers. Needs job_board.view OR candidates.view. */
export function listJobs() {
return request('/job/fetch')
}
/**
* Persisted scoring leaderboard. Needs candidates.view.
* Omit jobId for the whole pool across jobs; rows are ordered completed-by-
* score-desc, then failed rows.
*/
export function listCandidates({ jobId } = {}) {
return request('/candidate/fetch', { params: { job_id: jobId } })
}
/** One candidate row by id. Needs candidates.view. 404s on unknown ids. */
export function getCandidate(candidateId) {
return request('/candidate/fetch_by_id', { params: { candidate_id: candidateId } })
}
/**
* Score uploaded CV PDFs against a job post. Needs candidates.create.
* Multipart: unreadable/oversized/non-PDF files come back as rows with
* status "failed" instead of failing the batch. Re-scoring identical bytes
* against the same job updates the existing row (no duplicates).
*/
export function scoreUploads(jobId, files) {
const form = new FormData()
form.append('job_id', jobId)
for (const file of files) form.append('files', file, file.name)
return request('/candidate/score', { method: 'POST', body: form })
}
/**
* Score the decoded attachments of inbox messages against a job post.
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
* field the inbox list returns), not Graph message ids.
*/
export function scoreInbox(jobId, messageIds) {
return request('/candidate/score_inbox', {
method: 'POST',
body: { job_id: jobId, message_ids: messageIds },
})
}
/**
* Shared snake_case camelCase view-model mapper for candidate rows, so the
* three candidate screens agree on field names. Fields the backend does not
* store (email, phone, stage, education) are deliberately absent screens
* hide those affordances rather than render placeholders (Inbox precedent).
*/
export function toCandidateView(row) {
const name = row.candidate_name || row.filename || 'Unknown'
return {
id: row.id,
jobId: row.job_id,
name,
filename: row.filename,
source: row.source, // 'upload' | 'inbox'
currentTitle: row.job_title ?? null,
currentCompany: row.current_company ?? null,
experience: row.years_experience ?? null,
aiScore: row.match_score ?? null,
matchedSkills: Array.isArray(row.matched_keywords) ? row.matched_keywords : [],
missingSkills: Array.isArray(row.missing_keywords) ? row.missing_keywords : [],
critique: row.summary_critique ?? null,
scoringStatus: row.status, // 'completed' | 'failed'
errorCode: row.error_code ?? null,
errorMessage: row.error_message ?? null,
applied: row.created_at ? new Date(row.created_at) : null,
inboxMessageId: row.inbox_message_id ?? null,
}
}

View File

@ -51,14 +51,17 @@ export async function request(
const send = async () => {
const headers = { Accept: 'application/json' }
if (body != null) headers['Content-Type'] = 'application/json'
// FormData bodies (file uploads) set their own multipart boundary — adding a
// Content-Type here would break the request, and they must not be stringified.
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
if (body != null && !isForm) headers['Content-Type'] = 'application/json'
const bearer = token ?? (auth ? getAccessToken() : null)
if (bearer) headers.Authorization = `Bearer ${bearer}`
return fetch(buildUrl(path, params), {
method,
headers,
signal,
body: body != null ? JSON.stringify(body) : undefined,
body: body == null ? undefined : isForm ? body : JSON.stringify(body),
})
}

View File

@ -22,6 +22,15 @@ export const qk = {
applications: (p = {}) => ['mailbox', 'applications', p],
message: (id) => ['mailbox', 'message', id],
},
jobs: {
all: () => ['jobs'],
list: () => ['jobs', 'list'],
},
candidates: {
all: () => ['candidates'],
list: (p = {}) => ['candidates', 'list', p],
detail: (id) => ['candidates', 'detail', id],
},
// --- seed-backed buckets ---
// These are not "server state" — the cache IS the store for them, so every

View File

@ -1,72 +1,58 @@
/* The 8-tab candidate profile modal, split out of Candidates.jsx it was the
single largest block in js/candidates.js and deserves its own file. */
/* The candidate profile modal, on live backend data. Tabs that had no backing
store (notes, documents, feedback, fabricated timelines) are gone what
remains is exactly what the scoring engine knows about the candidate. */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { companies, fmtDate, moneyK, pick } from '../data/seed'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
const TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
const TABS = ['Overview', 'Scoring', 'File']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {
const { toast } = useToast()
export default function CandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) {
const [tab, setTab] = useState('Overview')
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
// The prototype called DB.pick() inline while rendering, so the "previous
// employer" changed every repaint. Fixed per candidate.
const priorCompany = useMemo(() => pick(companies), [])
const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)
const scored = c.scoringStatus === 'completed'
return (
<Modal
title="Candidate Profile"
subtitle={c.id}
subtitle={c.filename}
size="modal-lg"
onClose={onClose}
footer={
<>
<button
className={`btn btn-ghost star-btn${c.favorite ? ' on' : ''}`}
style={{ marginRight: 'auto' }}
onClick={() => onToggleFav(c)}
>
<Icon name="star" /> {c.favorite ? 'Favorited' : 'Favorite'}
</button>
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
<button className="btn btn-secondary" onClick={() => toast('Email drafted', 'info')}>
<Icon name="mail" /> Message
</button>
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
<Icon name="check" /> Advance Stage
</button>
<button className="btn btn-primary" onClick={onClose}>Close</button>
</>
}
>
<div className="profile-hero">
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{c.name}</div>
<div className="ph-role">{c.currentTitle} at {c.currentCompany}</div>
<div className="ph-role">
{c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''}
</div>
<div className="ph-tags">
<Badge>{c.stage}</Badge> <Badge className="b-gray">{c.source}</Badge>
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
{scored ? <Badge className="b-green">Scored</Badge> : <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>}
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
{c.experience != null && (
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
)}
</div>
</div>
<div style={{ textAlign: 'center' }}>
<ScoreChip score={c.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
{c.aiScore != null && (
<div style={{ textAlign: 'center' }}>
<ScoreChip score={c.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
)}
</div>
<div style={{ marginTop: 22 }}>
@ -77,200 +63,74 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
{tab === 'Overview' && (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{c.email}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{c.phone}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{c.location}</div></div>
<div className="info-item"><div className="il">Applied For</div><div className="iv">{c.jobTitle}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience} years</div></div>
<div className="info-item"><div className="il">Education</div><div className="iv">{c.education}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{c.source}</div></div>
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{c.recruiter}</div></div>
<div className="info-item"><div className="il">Applied On</div><div className="iv">{fmtDate(c.applied)}</div></div>
<div className="info-item"><div className="il">Expected Salary</div><div className="iv">{moneyK(c.salary)}</div></div>
<div className="info-item"><div className="il">Rating</div><div className="iv"> {c.rating} / 5.0</div></div>
<div className="info-item"><div className="il">Scored For</div><div className="iv">{jobTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany ?? '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience != null ? `${c.experience} years` : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
<div className="info-item"><div className="il">Added On</div><div className="iv">{c.applied ? fmtDate(c.applied) : '—'}</div></div>
</div>
<div style={LABEL}>Skills</div>
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
{scored && (
<>
<div style={LABEL}>Matched Skills</div>
<div className="k-tags">
{c.matchedSkills.length
? c.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)
: <span className="text-muted"></span>}
</div>
</>
)}
</>
)}
{tab === 'Resume' && (
<>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<h3 style={{ marginBottom: 4 }}>{c.name}</h3>
<p className="text-muted">{c.currentTitle} · {c.location}</p>
<div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Summary</div>
<p className="text-muted">
Results-driven {c.currentTitle.toLowerCase()} with {c.experience} years of experience
across {c.department.toLowerCase()}. Passionate about building high-quality products
and collaborating with cross-functional teams.
</p>
<div className="form-section-title">Experience</div>
<div className="info-item">
<div className="iv">{c.currentTitle} {c.currentCompany}</div>
<div className="il" style={{ textTransform: 'none' }}>2021 Present</div>
</div>
<div className="info-item" style={{ marginTop: 10 }}>
<div className="iv">Associate {priorCompany}</div>
<div className="il" style={{ textTransform: 'none' }}>2018 2021</div>
</div>
<div className="form-section-title">Education</div>
<div className="iv">{c.education}</div>
{tab === 'Scoring' && (
scored ? (
<>
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
<p className="text-muted" style={{ marginBottom: 18 }}>{c.critique ?? '—'}</p>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({c.matchedSkills.length})
</div>
</div>
<button className="btn btn-secondary" style={{ marginTop: 14 }} onClick={() => toast('Downloading resume.pdf', 'info')}>
<Icon name="download" /> Download PDF
</button>
</>
)}
{tab === 'Timeline' && (
<div className="timeline">
{[
{ icon: 'user-plus', title: 'Application received', meta: fmtDate(c.applied), desc: `Applied via ${c.source}` },
{ icon: 'star', title: 'AI screening completed', meta: '1 day later', desc: `Match score: ${c.aiScore}%` },
{ icon: 'phone', title: 'Recruiter screen', meta: '3 days later', desc: `Call with ${c.recruiter}` },
{ icon: 'calendar', title: 'Technical interview', meta: '1 week later', desc: 'Panel of 3 interviewers' },
{ icon: 'check', title: `Moved to ${c.stage}`, meta: 'Recently', desc: 'Current stage in pipeline' },
].map((e) => (
<div className="tl-item" key={e.title}>
<div className="tl-dot"><Icon name={e.icon} /></div>
<div className="tl-title">{e.title}</div>
<div className="tl-meta">{e.meta}</div>
<div className="tl-desc">{e.desc}</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{c.matchedSkills.length
? c.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
))}
</div>
)}
{tab === 'Interview' && (
candidateInterviews.length ? (
<div className="list-tight">
{candidateInterviews.map((iv) => (
<div className="list-row" key={iv.id}>
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="calendar" />
</span>
<div className="lr-main">
<div className="lr-title">{iv.type}</div>
<div className="lr-sub">{fmtDate(iv.when)} · {iv.meeting}</div>
</div>
<div className="lr-right"><Badge>{iv.status}</Badge></div>
</div>
))}
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Missing Skills ({c.missingSkills.length})
</div>
<div className="k-tags">
{c.missingSkills.length
? c.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
</>
) : (
<EmptyState icon="calendar" title="No interviews scheduled">
Schedule an interview to get started.
<EmptyState icon="target" title="Not scored">
{c.errorMessage ?? 'This CV could not be processed.'}
</EmptyState>
)
)}
{tab === 'Notes' && (
<>
<div className="form-field">
<label>Add a note</label>
<textarea placeholder="Write a private note about this candidate…" />
</div>
<button className="btn btn-primary btn-sm" style={{ margin: '10px 0 18px' }} onClick={() => toast('Note saved', 'success')}>
<Icon name="plus" /> Add Note
</button>
<div className="list-tight">
<div className="list-row">
<Avatar name={c.recruiter} />
<div className="lr-main">
<div className="lr-title">{c.recruiter}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
Strong communication skills, great culture fit. Recommend advancing.
</div>
<div className="lr-sub">2 days ago</div>
</div>
</div>
<div className="list-row">
<Avatar name="Asfand Ahmed" initials="AA" />
<div className="lr-main">
<div className="lr-title">Asfand Ahmed</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
Reviewed portfolio impressive work. Schedule technical round.
</div>
<div className="lr-sub">4 days ago</div>
</div>
</div>
</div>
</>
)}
{tab === 'Activity' && (
<div className="list-tight">
{[
{ icon: 'eye', tone: 'i-green', text: `Profile viewed by ${c.recruiter}`, when: '1h ago' },
{ icon: 'mail', tone: 'i-blue', text: 'Email sent: Interview invitation', when: '1 day ago' },
{ icon: 'star', tone: 'i-amber', text: `Assessment score updated to ${c.aiScore}%`, when: '2 days ago' },
{ icon: 'user-plus', tone: 'i-purple', text: `Applied for ${c.jobTitle}`, when: fmtDate(c.applied) },
].map((a) => (
<div className="list-row" key={a.text}>
<span className={`kpi-icn ${a.tone}`} style={{ width: 34, height: 34, borderRadius: 9 }}>
<Icon name={a.icon} />
</span>
<div className="lr-main">
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>{a.text}</div>
<div className="lr-sub">{a.when}</div>
</div>
</div>
))}
{tab === 'File' && (
<div className="info-grid">
<div className="info-item"><div className="il">File Name</div><div className="iv">{c.filename}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
{c.inboxMessageId && (
<div className="info-item"><div className="il">Inbox Message</div><div className="iv">{c.inboxMessageId}</div></div>
)}
{!scored && (
<>
<div className="info-item"><div className="il">Error</div><div className="iv">{c.errorCode ?? '—'}</div></div>
<div className="info-item"><div className="il">Detail</div><div className="iv">{c.errorMessage ?? '—'}</div></div>
</>
)}
</div>
)}
{tab === 'Documents' && (
<div className="list-tight">
{[
{ n: 'Resume.pdf', s: '284 KB' }, { n: 'Cover_Letter.pdf', s: '112 KB' },
{ n: 'Portfolio.pdf', s: '4.2 MB' }, { n: 'References.docx', s: '48 KB' },
].map((d) => (
<div className="list-row" key={d.n}>
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="file" />
</span>
<div className="lr-main"><div className="lr-title">{d.n}</div><div className="lr-sub">{d.s}</div></div>
<button className="act-btn" onClick={() => toast(`Downloading ${d.n}`, 'info')}>
<Icon name="download" />
</button>
</div>
))}
</div>
)}
{tab === 'Feedback' && (
<>
<div className="list-tight">
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => {
const r = recruiters[i]
if (!r) return null
const notes = [
'Excellent technical depth and clear communication.',
'Good problem solving, would benefit from more system design exposure.',
'Solid candidate, positive team energy.',
]
return (
<div className="list-row" key={score}>
<Avatar name={r.name} initials={r.initials} color={r.color} />
<div className="lr-main">
<div className="lr-title">{r.name}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{notes[i]}</div>
</div>
<div className="lr-right"><Badge>{score}</Badge></div>
</div>
)
})}
</div>
<button className="btn btn-primary btn-sm" style={{ marginTop: 14 }} onClick={() => toast('Scorecard form opened', 'info')}>
<Icon name="plus" /> Submit Scorecard
</button>
</>
)}
</div>
</Modal>
)

View File

@ -1,74 +1,88 @@
/* ============================================================
Candidates the largest screen in the app: a 14-facet filter panel, a
composite relevance sort, a multi-select bulk bar, favourites, a
recently-viewed strip, the ATS-match modal and the 8-tab profile
(CandidateProfile.jsx).
Candidates the scored-candidate pool, on live backend data.
Uses the headless `useDataTable` rather than <DataTable/>, because the
selection column needs to render against a Set this component owns.
Rows come from GET /candidate/fetch (all jobs) via the shared
toCandidateView mapper. Facets, columns and actions that had no backing
column (stage, recruiter, notice period, favourites) are gone rather than
rendered as placeholders the Inbox screen set that precedent. Adding
candidates happens through CV Import (real scoring), not a manual form.
============================================================ */
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Pagination, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, FieldError, Icon, ProgressBar, ScoreChip } from '../ui/primitives'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import CandidateProfile from './CandidateProfile'
import { qk } from '../lib/queryKeys'
import { persist, seedQuery, useSeedMutation } from '../data/seedQueries'
import {
atsRecommendationClass, avatarColor, departments, educationLevels, getJob,
initials as initialsOf, int, locations, skillsPool, sources, stages, TODAY,
} from '../data/seed'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import { persist } from '../data/seedQueries'
import { atsRecommendationClass, avatarColor, initials as initialsOf } from '../data/seed'
const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
const EXP_BUCKETS = ['0-2', '3-5', '6-9', '10+']
const ATS_BANDS = ['85+', '70-84', '<70']
const INTERVIEW_STATES = ['Not Scheduled', 'Scheduled', 'Completed']
const NOTICE = ['Immediate', '2 weeks', '1 month', '2 months', '3 months']
const AVAILABILITY = ['Immediate', '2 weeks', '1 month', 'Passive']
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
const EMPTY_FILTERS = { job: '', skill: '', source: '', ats: '', status: '' }
const EMPTY_FILTERS = {
job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '',
manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '',
async function fetchCandidates() {
const res = await candidatesApi.listCandidates()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(candidatesApi.toCandidateView)
}
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
}
function recommendationOf(c) {
if (c.aiScore == null) return 'Weak Match'
return c.aiScore >= 82 ? 'Strong Match' : c.aiScore >= 65 ? 'Potential Match' : 'Weak Match'
}
export default function Candidates() {
const { toast } = useToast()
const qc = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates })
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data])
const jobsById = useMemo(
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
[jobsQuery.data],
)
const { data: recentlyViewed = [] } = useQuery({
queryKey: qk.seed.recentlyViewed(),
queryFn: async () => [],
staleTime: Infinity,
gcTime: Infinity,
})
const updateCandidates = useSeedMutation('candidates')
const [q, setQ] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('relevance')
const [selected, setSelected] = useState(() => new Set())
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
const [bulkAssigning, setBulkAssigning] = useState(false)
/** ATS + matched-skill ratio + recency. Verbatim from js/candidates.js:14-20. */
const jobTitleOf = useCallback(
(c) => jobsById[c.jobId]?.title ?? '—',
[jobsById],
)
/** ATS score + matched-skill ratio + recency same shape as before, but every
input is now real: matched/missing come from the model, applied from the DB. */
const relevance = useCallback((c) => {
const req = (getJob(c.jobId) || {}).skills || []
const skillRatio = req.length ? c.matchedSkills.length / req.length : 0.5
const recency = 1 - Math.min(1, (TODAY - c.applied) / (90 * 864e5))
if (c.aiScore == null) return 0
const total = c.matchedSkills.length + c.missingSkills.length
const skillRatio = total ? c.matchedSkills.length / total : 0.5
const recency = c.applied ? 1 - Math.min(1, (Date.now() - c.applied) / (90 * 864e5)) : 0.5
return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10)
}, [])
@ -84,135 +98,70 @@ export default function Candidates() {
[qc],
)
// Deep links from global search, dashboard, pipeline, calendar, interviews
// Deep links from Talent Pool, global search, dashboard
useEffect(() => {
const st = location.state
if (!st) return
if (st.openAdd) setAdding(true)
if (st.openCandidate) {
const c = candidates.find((x) => x.id === st.openCandidate)
if (c) openProfile(c)
}
if (!st?.openCandidate) return
const c = candidates.find((x) => x.id === st.openCandidate)
if (c) openProfile(c)
}, [location.state, candidates, openProfile])
const jobTitles = useMemo(() => [...new Set(candidates.map((c) => c.jobTitle))], [candidates])
const skillOptions = useMemo(() => {
const set = new Set()
for (const c of candidates) for (const s of c.matchedSkills) set.add(s)
return [...set].sort((a, b) => a.localeCompare(b)).slice(0, 40)
}, [candidates])
const jobOptions = useMemo(
() => (jobsQuery.data ?? []).map((j) => j.title),
[jobsQuery.data],
)
const rows = useMemo(() => {
const f = filters
let list = candidates.filter((c) => {
if (f.job && c.jobTitle !== f.job) return false
if (f.skill && !c.skills.includes(f.skill)) return false
if (f.dept && c.department !== f.dept) return false
if (f.location && c.location !== f.location) return false
if (f.exp === '0-2' && c.experience > 2) return false
if (f.exp === '3-5' && (c.experience < 3 || c.experience > 5)) return false
if (f.exp === '6-9' && (c.experience < 6 || c.experience > 9)) return false
if (f.exp === '10+' && c.experience < 10) return false
if (f.edu && c.education !== f.edu) return false
if (f.recruiter && c.recruiter !== f.recruiter) return false
if (f.manager) {
const job = getJob(c.jobId)
if (!job || job.manager !== f.manager) return false
}
if (f.job && jobTitleOf(c) !== f.job) return false
if (f.skill && !c.matchedSkills.includes(f.skill)) return false
if (f.source && c.source !== f.source) return false
if (f.ats === '85+' && c.aiScore < 85) return false
if (f.ats === '70-84' && (c.aiScore < 70 || c.aiScore > 84)) return false
if (f.ats === '<70' && c.aiScore >= 70) return false
if (f.stage && c.stage !== f.stage) return false
if (f.interview && c.interviewStatus !== f.interview) return false
if (f.notice && c.noticePeriod !== f.notice) return false
if (f.availability && c.availability !== f.availability) return false
if (f.status === 'Scored' && c.scoringStatus !== 'completed') return false
if (f.status === 'Failed' && c.scoringStatus !== 'failed') return false
if (f.ats === '85+' && (c.aiScore == null || c.aiScore < 85)) return false
if (f.ats === '70-84' && (c.aiScore == null || c.aiScore < 70 || c.aiScore > 84)) return false
if (f.ats === '<70' && (c.aiScore == null || c.aiScore >= 70)) return false
if (q) {
const term = q.toLowerCase()
const hay = (c.name + c.email + c.jobTitle + c.currentCompany + c.recruiter + c.skills.join(' ')).toLowerCase()
const hay = [
c.name, c.filename, c.currentTitle ?? '', c.currentCompany ?? '',
c.matchedSkills.join(' '),
].join(' ').toLowerCase()
if (!hay.includes(term)) return false
}
return true
})
if (sortMode === 'relevance') list = [...list].sort((a, b) => relevance(b) - relevance(a))
else if (sortMode === 'ats') list = [...list].sort((a, b) => b.aiScore - a.aiScore)
else if (sortMode === 'recent') list = [...list].sort((a, b) => b.applied - a.applied)
else if (sortMode === 'ats') list = [...list].sort((a, b) => (b.aiScore ?? -1) - (a.aiScore ?? -1))
else if (sortMode === 'recent') list = [...list].sort((a, b) => (b.applied ?? 0) - (a.applied ?? 0))
else if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
return list
}, [candidates, filters, q, sortMode, relevance])
}, [candidates, filters, q, sortMode, relevance, jobTitleOf])
const columns = useMemo(
() => [
{ key: '_sel', label: '' },
{ key: 'name', label: 'Candidate', sortable: true },
{ key: 'jobTitle', label: 'Applied Job', sortable: true },
{ key: '_job', label: 'Scored For', sortable: true, sortValue: jobTitleOf },
{ key: 'experience', label: 'Exp', sortable: true, align: 'center' },
{ key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: relevance },
{ key: 'stage', label: 'Stage', sortable: true },
{ key: 'scoringStatus', label: 'Status', sortable: true },
{ key: 'aiScore', label: 'ATS', sortable: true, align: 'center' },
{ key: 'availability', label: 'Availability' },
{ key: 'applied', label: 'Added', sortable: true },
{ key: '_a', label: 'Actions', align: 'right' },
],
[relevance],
[relevance, jobTitleOf],
)
const t = useDataTable({ columns, rows, pageSize: 10 })
function toggleSelect(id) {
setSelected((s) => {
const next = new Set(s)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
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)))
toast(`${c.name} moved to ${stage}`, 'success')
}
function bulk(action) {
const ids = [...selected]
if (!ids.length) return
if (action === 'email') {
toast(`Bulk email drafted to ${ids.length} candidates`, 'success')
setSelected(new Set())
return
}
if (action === 'assign') {
setBulkAssigning(true)
return
}
if (action === 'advance') {
updateCandidates((cs) =>
cs.map((c) => {
if (!selected.has(c.id)) return c
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) return c
const stage = STAGE_ORDER[i + 1]
return { ...c, stage, status: stage }
}),
)
toast(`${ids.length} candidates advanced`, 'success')
}
if (action === 'reject') {
updateCandidates((cs) =>
cs.map((c) => (selected.has(c.id) ? { ...c, stage: 'Rejected', status: 'Rejected' } : c)),
)
toast(`${ids.length} candidates rejected`, 'warning')
}
setSelected(new Set())
}
const recentChips = recentlyViewed
.slice(0, 6)
.map((id) => candidates.find((c) => c.id === id))
@ -220,6 +169,14 @@ export default function Candidates() {
const setFilter = (k, v) => setFilters((f) => ({ ...f, [k]: v }))
function openAts(c) {
if (c.scoringStatus !== 'completed') {
toast('This CV could not be scored — no match analysis available', 'info')
return
}
setAtsFor(c)
}
return (
<div className="page">
<div className="page-head">
@ -230,14 +187,11 @@ export default function Candidates() {
</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Search saved', 'success')}>
<Icon name="bookmark" /> Save Search
</button>
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}>
<Icon name="download" /> Export
</button>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<Icon name="plus" /> Add Candidate
<button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Import CVs
</button>
</div>
</div>
@ -247,25 +201,12 @@ export default function Candidates() {
<span className="text-muted text-sm fw-600">Recently viewed:</span>
{recentChips.map((c) => (
<button key={c.id} className="prompt-chip" style={{ padding: '5px 10px' }} onClick={() => openProfile(c)}>
<Avatar name={c.name} initials={c.initials} color={c.color} /> {c.name.split(' ')[0]}
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} /> {c.name.split(' ')[0]}
</button>
))}
</div>
)}
{selected.size > 0 && (
<div className="bulk-bar" style={{ display: 'flex' }}>
<span className="checkbox on"><Icon name="check" /></span>
<span className="fw-600">{selected.size} selected</span>
<div style={{ flex: 1 }} />
<button className="btn btn-sm" onClick={() => bulk('email')}><Icon name="mail" /> Bulk Email</button>
<button className="btn btn-sm" onClick={() => bulk('assign')}><Icon name="users" /> Assign</button>
<button className="btn btn-sm" onClick={() => bulk('advance')}><Icon name="check" /> Advance</button>
<button className="btn btn-sm" onClick={() => bulk('reject')}><Icon name="x" /> Reject</button>
<button className="btn btn-sm" onClick={() => setSelected(new Set())}><Icon name="x" /> Clear</button>
</div>
)}
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
@ -291,196 +232,171 @@ export default function Candidates() {
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobTitles} />
<Facet label="Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillsPool} />
<Facet label="Department" value={filters.dept} onChange={(v) => setFilter('dept', v)} any="Any Dept" options={departments} />
<Facet label="Location" value={filters.location} onChange={(v) => setFilter('location', v)} any="Any Location" options={locations} />
<Facet label="Experience" value={filters.exp} onChange={(v) => setFilter('exp', v)} any="Any Exp" options={EXP_BUCKETS} />
<Facet label="Education" value={filters.edu} onChange={(v) => setFilter('edu', v)} any="Any" options={educationLevels} />
<Facet label="Recruiter" value={filters.recruiter} onChange={(v) => setFilter('recruiter', v)} any="Any Recruiter" options={recruiters.map((r) => r.name)} />
<Facet label="Hiring Manager" value={filters.manager} onChange={(v) => setFilter('manager', v)} any="Any Manager" options={managers.map((m) => m.name)} />
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={sources} />
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobOptions} />
<Facet label="Matched Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillOptions} />
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={['upload', 'inbox']} labels={SOURCE_LABEL} />
<Facet label="ATS Score" value={filters.ats} onChange={(v) => setFilter('ats', v)} any="Any Score" options={ATS_BANDS} />
<Facet label="Pipeline Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any Stage" options={stages} />
<Facet label="Interview Status" value={filters.interview} onChange={(v) => setFilter('interview', v)} any="Any" options={INTERVIEW_STATES} />
<Facet label="Notice Period" value={filters.notice} onChange={(v) => setFilter('notice', v)} any="Any" options={NOTICE} />
<Facet label="Availability" value={filters.availability} onChange={(v) => setFilter('availability', v)} any="Any" options={AVAILABILITY} />
<Facet label="Status" value={filters.status} onChange={(v) => setFilter('status', v)} any="Any Status" options={['Scored', 'Failed']} />
</div>
)}
</div>
<div className="dt">
<div className="table-wrap">
<table className="data">
<thead>
<tr>
{columns.map((c) => {
const isSorted = t.sort.key === c.key
const cls = [
c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ')
return (
<th
key={c.key}
className={cls}
style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
>
{c.label}
{c.sortable && (
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody>
{t.pageRows.length === 0 ? (
<tr><td colSpan={columns.length}><EmptyState /></td></tr>
) : (
t.pageRows.map((c) => (
<tr key={c.id}>
<td>
<span
className={`checkbox ${selected.has(c.id) ? 'on' : ''}`}
onClick={() => toggleSelect(c.id)}
role="checkbox"
aria-checked={selected.has(c.id)}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelect(c.id) } }}
{candidatesQuery.isPending && (
<div className="card-body">
<EmptyState icon="users" title="Loading…">Fetching candidates from the server.</EmptyState>
</div>
)}
{candidatesQuery.isError && (
<div className="card-body">
<EmptyState icon="users" title="Couldnt load candidates">
{friendlyAuthError(candidatesQuery.error, 'Request failed')}
</EmptyState>
</div>
)}
{candidatesQuery.isSuccess && (
<div className="dt">
<div className="table-wrap">
<table className="data">
<thead>
<tr>
{columns.map((c) => {
const isSorted = t.sort.key === c.key
const cls = [
c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ')
return (
<th
key={c.key}
className={cls}
style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
>
<Icon name="check" />
</span>
</td>
<td>
<div className="user-cell">
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div>
<div className="cell-primary">
{c.name}{' '}
{c.favorite && (
<span className="star-btn on" style={{ display: 'inline' }}><Icon name="star" /></span>
)}
</div>
<div className="cell-sub">{c.currentTitle} · {c.location}</div>
</div>
</div>
</td>
<td>
<div className="text-sm">{c.jobTitle}</div>
<div className="cell-sub">{c.department}</div>
</td>
<td style={{ textAlign: 'center' }}><b>{c.experience}</b>y</td>
<td style={{ textAlign: 'center' }}>
<span className={`badge ${atsRecommendationClass(c.recommendation)} badge-plain`}>
{relevance(c)}%
</span>
</td>
<td><Badge>{c.stage}</Badge></td>
<td style={{ textAlign: 'center' }}>
<span style={{ cursor: 'pointer' }} onClick={() => setAtsFor(c)}>
<ScoreChip score={c.aiScore} />
</span>
</td>
<td>
<span className="text-sm">{c.availability}</span>
<div className="cell-sub">{c.noticePeriod} notice</div>
</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className={`act-btn star-btn ${c.favorite ? 'on' : ''}`} data-tip="Favorite" onClick={() => toggleFav(c)}>
<Icon name="star" />
</button>
<button className="act-btn" data-tip="ATS Match" onClick={() => setAtsFor(c)}><Icon name="target" /></button>
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Advance" onClick={() => advance(c)}><Icon name="check" /></button>
</div>
{c.label}
{c.sortable && (
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody>
{t.pageRows.length === 0 ? (
<tr>
<td colSpan={columns.length}>
<EmptyState title="No candidates yet">
Score resumes in CV Import to fill this table.
</EmptyState>
</td>
</tr>
))
)}
</tbody>
</table>
) : (
t.pageRows.map((c) => (
<tr key={c.id}>
<td>
<div className="user-cell">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} />
<div>
<div className="cell-primary">{c.name}</div>
<div className="cell-sub">
{c.currentTitle ?? c.filename}
{c.currentCompany ? ` · ${c.currentCompany}` : ''}
</div>
</div>
</div>
</td>
<td>
<div className="text-sm">{jobTitleOf(c)}</div>
<div className="cell-sub">{SOURCE_LABEL[c.source] ?? c.source}</div>
</td>
<td style={{ textAlign: 'center' }}>
{c.experience != null ? <><b>{c.experience}</b>y</> : '—'}
</td>
<td style={{ textAlign: 'center' }}>
{c.scoringStatus === 'completed' ? (
<span className={`badge ${atsRecommendationClass(recommendationOf(c))} badge-plain`}>
{relevance(c)}%
</span>
) : '—'}
</td>
<td>
{c.scoringStatus === 'completed'
? <Badge className="b-green">Scored</Badge>
: <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>}
</td>
<td style={{ textAlign: 'center' }}>
{c.aiScore != null ? (
<span style={{ cursor: 'pointer' }} onClick={() => openAts(c)}>
<ScoreChip score={c.aiScore} />
</span>
) : '—'}
</td>
<td>
<span className="text-sm">
{c.applied ? c.applied.toLocaleDateString() : '—'}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className="act-btn" data-tip="ATS Match" onClick={() => openAts(c)}><Icon name="target" /></button>
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination {...t} />
</div>
<Pagination {...t} />
</div>
)}
</div>
{atsFor && <AtsMatch candidate={atsFor} onClose={() => setAtsFor(null)} onProfile={(c) => { setAtsFor(null); openProfile(c) }} />}
{atsFor && (
<AtsMatch
candidate={atsFor}
jobTitle={jobTitleOf(atsFor)}
onClose={() => setAtsFor(null)}
onProfile={(c) => { setAtsFor(null); openProfile(c) }}
/>
)}
{profileFor && (
<CandidateProfile
candidate={candidates.find((c) => c.id === profileFor.id) ?? profileFor}
jobTitle={jobTitleOf(profileFor)}
onClose={() => setProfileFor(null)}
onAdvance={advance}
onToggleFav={toggleFav}
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
/>
)}
{bulkAssigning && (
<BulkAssign
count={selected.size}
recruiters={recruiters}
onClose={() => setBulkAssigning(false)}
onSave={(name) => {
updateCandidates((cs) => cs.map((c) => (selected.has(c.id) ? { ...c, recruiter: name } : c)))
setBulkAssigning(false)
setSelected(new Set())
toast('Recruiter assigned to selected candidates', 'success')
}}
/>
)}
{adding && (
<AddCandidate
jobs={jobs}
count={candidates.length}
onClose={() => setAdding(false)}
onSave={(c) => {
updateCandidates((cs) => [c, ...cs])
setAdding(false)
toast('Candidate added to pipeline', 'success')
}}
onInvalid={() => toast('Please fix the highlighted fields', 'error')}
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
/>
)}
</div>
)
}
function Facet({ label, value, onChange, any, options }) {
function Facet({ label, value, onChange, any, options, labels }) {
return (
<div className="form-field">
<label>{label}</label>
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{any}</option>
{options.map((o) => <option key={o}>{o}</option>)}
{options.map((o) => <option key={o} value={o}>{labels?.[o] ?? o}</option>)}
</select>
</div>
)
}
function AtsMatch({ candidate: c, onClose, onProfile }) {
const sub = c.subScores
const recCls = c.recommendation === 'Strong Match' ? 'recc-strong'
: c.recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
const recommendation = recommendationOf(c)
const recCls = recommendation === 'Strong Match' ? 'recc-strong'
: recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
const ringColor = c.aiScore >= 82 ? 'var(--success)' : c.aiScore >= 65 ? 'var(--warning)' : 'var(--danger)'
const Row = ({ label, val }) => (
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
<span style={{ width: 110, fontSize: 13 }}>{label}</span>
<div style={{ flex: 1 }}><ProgressBar pct={val} /></div>
<b style={{ width: 42, textAlign: 'right' }}>{val}%</b>
</div>
)
return (
<Modal
title="ATS Match Analysis"
subtitle={`${c.id} · ${c.jobTitle}`}
subtitle={jobTitle}
size="modal-lg"
onClose={onClose}
footer={
@ -492,11 +408,11 @@ function AtsMatch({ candidate: c, onClose, onProfile }) {
>
<div className={`recc-banner ${recCls}`}>
<span className="recc-icn">
<Icon name={c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
<Icon name={recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
</span>
<div style={{ flex: 1 }}>
<div className="fw-600" style={{ fontSize: 15 }}>{c.recommendation}</div>
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name} for {c.jobTitle}</div>
<div className="fw-600" style={{ fontSize: 15 }}>{recommendation}</div>
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name} for {jobTitle}</div>
</div>
</div>
@ -510,12 +426,8 @@ function AtsMatch({ candidate: c, onClose, onProfile }) {
</div>
</div>
<div>
<Row label="Skills" val={sub.skills} />
<Row label="Experience" val={sub.experience} />
<Row label="Education" val={sub.education} />
<Row label="Keywords" val={sub.keywords} />
<Row label="Location" val={sub.location} />
<Row label="Salary" val={sub.salary} />
<div className="form-section-title" style={{ marginTop: 0 }}>Assessment</div>
<p className="text-muted" style={{ fontSize: 13 }}>{c.critique ?? '—'}</p>
</div>
</div>
@ -543,118 +455,10 @@ function AtsMatch({ candidate: c, onClose, onProfile }) {
<div className="divider" />
<p className="text-muted text-sm">
<Icon name="sparkles" /> Score computed from JD keywords, resume parsing, experience,
education, location and salary alignment. Connect an AI model to refine with semantic matching.
<Icon name="sparkles" /> Scored by the ATS engine against the job post's requirements.
Matched skills are verified to appear in the resume text; the one-line assessment is
model-generated and evidence-based.
</p>
</Modal>
)
}
function BulkAssign({ count, recruiters, onClose, onSave }) {
const [name, setName] = useState(recruiters[0]?.name ?? '')
return (
<Modal
title="Bulk Assign Recruiter"
subtitle={`${count} candidates`}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
</>
}
>
<div className="form-field">
<label>Assign to</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
</Modal>
)
}
function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
const open = jobs.filter((j) => j.status === 'Open')
const form = useFormState({
name: '', email: '', phone: '', job: open[0]?.title ?? '',
experience: '3', company: '', source: sources[0], stage: stages[0],
})
function submit() {
const v = form.values
const errors = {}
if (!v.name.trim()) errors.name = 'Required'
if (!/^\S+@\S+\.\S+$/.test(v.email)) errors.email = 'Valid email required'
form.setErrors(errors)
if (Object.keys(errors).length) {
onInvalid()
return
}
const job = jobs.find((j) => j.title === v.job) || jobs[0]
const score = int(55, 95)
onSave({
id: `CAN-${5001 + count}`,
name: v.name, initials: initialsOf(v.name), color: avatarColor(v.name),
email: v.email, phone: v.phone || '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: Number(v.experience) || 1, currentCompany: v.company || '—',
currentTitle: job.title, location: job.location,
stage: v.stage, status: v.stage, aiScore: score, source: v.source,
recruiter: job.recruiter, recruiterId: job.recruiterId,
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: 120000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match',
subScores: { skills: score, experience: 80, education: 80, keywords: score, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
})
}
const field = (n) => ({ value: form.values[n], onChange: (e) => form.setField(n, e.target.value) })
return (
<Modal
title="Add Candidate"
subtitle="Manually add a candidate to the pipeline"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}><Icon name="check" /> Add Candidate</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field">
<label>Full Name <span className="req">*</span></label>
<input {...field('name')} className={form.errors.name ? 'err' : ''} placeholder="Jane Doe" />
<FieldError>{form.errors.name}</FieldError>
</div>
<div className="form-field">
<label>Email <span className="req">*</span></label>
<input type="email" {...field('email')} className={form.errors.email ? 'err' : ''} placeholder="jane@email.com" />
<FieldError>{form.errors.email}</FieldError>
</div>
<div className="form-field"><label>Phone</label><input {...field('phone')} placeholder="+1 (555) 000-0000" /></div>
<div className="form-field">
<label>Applied Job <span className="req">*</span></label>
<select {...field('job')}>{open.map((j) => <option key={j.id}>{j.title}</option>)}</select>
</div>
<div className="form-field"><label>Experience (years)</label><input type="number" {...field('experience')} /></div>
<div className="form-field"><label>Current Company</label><input {...field('company')} placeholder="Acme Inc." /></div>
<div className="form-field">
<label>Source</label>
<select {...field('source')}>{sources.map((s) => <option key={s}>{s}</option>)}</select>
</div>
<div className="form-field">
<label>Stage</label>
<select {...field('stage')}>{stages.map((s) => <option key={s}>{s}</option>)}</select>
</div>
</div>
</form>
</Modal>
)
}

View File

@ -1,169 +1,135 @@
/* ============================================================
CV Import the UX shape is right; the mechanics are still simulated.
CV Import real upload score persist flow.
The prototype's dropzone read only `e.dataTransfer.files.length` and threw
the files away, then invented a queue with setInterval-driven progress. That
is preserved deliberately: there is no upload endpoint, no object storage and
no parser behind this yet, so pretending otherwise would be worse than the
honest "processed locally in this demo" label the screen already carries.
Files go to POST /candidate/score as one multipart batch: the backend
extracts each PDF, scores it against the selected job with the ATS engine,
and persists a row per file. Unreadable/oversized/non-PDF files come back
as status "failed" rows instead of failing the batch, and re-uploading the
same bytes updates the existing record (content-hash dedupe) so there is
no separate "import" step and no duplicate modal anymore.
============================================================ */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Badge, Icon, ScoreChip } from '../ui/primitives'
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import {
avatarColor, companies, initials as initialsOf, int, locations, pick, TODAY,
} from '../data/seed'
const FIRST = ['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Priya', 'Diego', 'Yuki', 'Omar']
const LAST = ['Chen', 'Patel', 'Kim', 'Garcia', 'Silva', 'Ahmed', 'Novak', 'Reyes', 'Khan', 'Costa']
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
const STEPS = [
{ i: 'file', t: 'Resume parsing', d: 'Extract name, contact, experience, skills & education' },
{ i: 'target', t: 'ATS scoring', d: 'Generate a match score against the requisition' },
{ i: 'briefcase', t: 'Job matching', d: 'Suggest the best-matching open roles' },
{ i: 'users', t: 'Duplicate detection', d: 'Flag candidates already in the system' },
{ i: 'user-plus', t: 'Profile creation', d: 'Create a candidate profile in Applied stage' },
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
{ i: 'target', t: 'ATS scoring', d: 'LLM match score with matched & missing skills vs the selected job' },
{ i: 'users', t: 'Duplicate detection', d: 'Re-uploading the same file updates its existing record' },
{ i: 'user-plus', t: 'Saved to pool', d: 'Results persist — see Candidates and Talent Pool' },
]
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
}
function fmtSize(bytes) {
if (!Number.isFinite(bytes)) return ''
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
let rowSeq = 0
export default function CvImport() {
const { toast } = useToast()
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const updateCandidates = useSeedMutation('candidates')
const qc = useQueryClient()
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const jobs = jobsQuery.data ?? []
const [jobId, setJobId] = useState('')
const [queue, setQueue] = useState([])
const [dragging, setDragging] = useState(false)
const [duplicateFor, setDuplicateFor] = useState(null)
const timers = useRef(new Set())
const fileInput = useRef(null)
useEffect(() => {
const set = timers.current
return () => {
set.forEach((t) => { clearInterval(t); clearTimeout(t) })
set.clear()
}
}, [])
const advance = useCallback((id) => {
const tick = setInterval(() => {
const scoring = useMutation({
mutationFn: ({ job, files }) => candidatesApi.scoreUploads(job, files),
onSuccess: (res, vars) => {
const rows = Array.isArray(res?.data) ? res.data : []
setQueue((q) =>
q.map((item) => {
if (item.id !== id || item.status !== 'Uploading') return item
const progress = Math.min(100, item.progress + int(12, 30))
if (progress >= 100) {
clearInterval(tick)
timers.current.delete(tick)
const done = setTimeout(() => {
setQueue((q2) =>
q2.map((x) => (x.id === id ? { ...x, status: 'Ready', atsScore: int(52, 96) } : x)),
)
timers.current.delete(done)
}, 700 + int(0, 500))
timers.current.add(done)
return { ...item, progress: 100, status: 'Parsing' }
if (!vars.rowIds.includes(item.id)) return item
const match = rows.find((r) => r.filename === item.file)
if (!match) return { ...item, status: 'Failed', error: 'NO_RESULT' }
if (match.status !== 'completed') {
return { ...item, status: 'Failed', error: match.error_code || 'FAILED' }
}
return {
...item,
status: 'Ready',
name: match.candidate_name || item.file,
atsScore: match.match_score,
critique: match.summary_critique,
}
return { ...item, progress }
}),
)
}, 220)
timers.current.add(tick)
}, [])
const simulate = useCallback(
(count, isZip) => {
const n = isZip ? 8 : count
const open = jobs.filter((j) => j.status === 'Open')
const items = []
for (let k = 0; k < n; k++) {
const name = `${pick(FIRST)} ${pick(LAST)}`
items.push({
id: `UP-${Math.random().toString(36).slice(2, 8)}`,
name,
file: `${name.split(' ')[0]}_Resume.${pick(['pdf', 'docx', 'doc'])}`,
size: `${int(120, 620)} KB`,
progress: 0,
status: 'Uploading',
atsScore: null,
job: pick(open.length ? open : jobs),
duplicate: Math.random() < 0.18,
imported: false,
})
}
setQueue((q) => [...q, ...items])
items.forEach((i) => advance(i.id))
toast(isZip ? 'ZIP extracted — 8 resumes queued' : `${n} file(s) uploaded`, 'info')
qc.invalidateQueries({ queryKey: qk.candidates.all() })
const ok = rows.filter((r) => r.status === 'completed').length
const failed = rows.length - ok
toast(
failed
? `${ok} scored, ${failed} failed — results saved to the candidate pool`
: `${ok} resume${ok === 1 ? '' : 's'} scored and saved`,
failed ? 'warning' : 'success',
)
},
[jobs, advance, toast],
)
const doImport = useCallback(
(id) => {
const item = queue.find((x) => x.id === id)
if (!item || item.imported) return
const job = item.job
updateCandidates((cs) => [
{
id: `CAN-${5001 + cs.length}`,
name: item.name,
initials: initialsOf(item.name),
color: avatarColor(item.name),
email: `${item.name.toLowerCase().replace(/ /g, '.')}@email.com`,
phone: '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: int(2, 12), currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: item.atsScore, source: 'Manual CV Upload',
recruiter: job.recruiter, recruiterId: '',
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
// Kept verbatim from the prototype, including the literal constants
// this breakdown is fabricated and is flagged as the most misleading
// artefact in the repo (01-repository-assessment.md §2.2).
subScores: { skills: item.atsScore, experience: 80, education: 80, keywords: item.atsScore, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
},
...cs,
])
setQueue((q) => q.map((x) => (x.id === id ? { ...x, imported: true } : x)))
toast(`${item.name} imported → ${job.title}`, 'success')
onError: (err, vars) => {
setQueue((q) =>
q.map((item) =>
vars.rowIds.includes(item.id) ? { ...item, status: 'Failed', error: 'REQUEST_FAILED' } : item,
),
)
toast(friendlyAuthError(err, 'Scoring failed'), 'error')
},
[queue, updateCandidates, toast],
)
})
function importOne(item) {
if (item.duplicate) setDuplicateFor(item)
else doImport(item.id)
}
function importAll() {
const ready = queue.filter((i) => i.status === 'Ready' && !i.imported && !i.duplicate)
if (!ready.length) {
toast('No files ready to import', 'warning')
function handleFiles(fileList) {
const all = Array.from(fileList || [])
if (!all.length) return
if (!jobId) {
toast('Select a job to score against first', 'warning')
return
}
ready.forEach((i) => doImport(i.id))
toast(`${ready.length} candidates imported`, 'success')
const files = all.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
const skipped = all.length - files.length
if (skipped) toast(`Only PDF resumes are supported — ${skipped} file(s) skipped`, 'warning')
if (!files.length) return
const items = files.map((f) => ({
id: `UP-${++rowSeq}-${Date.now()}`,
name: f.name,
file: f.name,
size: fmtSize(f.size),
status: 'Scoring',
atsScore: null,
critique: null,
error: null,
}))
setQueue((q) => [...q, ...items])
scoring.mutate({ job: jobId, files, rowIds: items.map((i) => i.id) })
}
const importedCount = queue.filter((i) => i.imported).length
const scored = queue.filter((i) => i.status === 'Ready').length
const failed = queue.filter((i) => i.status === 'Failed').length
const selectedJob = jobs.find((j) => j.id === jobId)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">CV Import</h1>
<p className="page-sub">Upload resumes we parse, score, match, and dedupe automatically</p>
<p className="page-sub">Upload resume PDFs parsed, scored against a job, and saved automatically</p>
</div>
<div className="page-head-actions">
<span className="integration-status pending"><span className="pulse" />AI Resume Parser · Ready</span>
<span className="integration-status pending"><span className="pulse" />AI Resume Scoring · Live</span>
</div>
</div>
@ -171,45 +137,59 @@ export default function CvImport() {
<div>
<div className="card mb-18">
<div className="card-body">
<div className="flex items-center gap-8" style={{ marginBottom: 16 }}>
<span className="fw-600 text-sm" style={{ flexShrink: 0 }}>Score against</span>
<select
className="select"
style={{ flex: 1 }}
value={jobId}
onChange={(e) => setJobId(e.target.value)}
>
<option value="">Select a job post</option>
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select>
</div>
{jobsQuery.isError && (
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
{friendlyAuthError(jobsQuery.error, 'Could not load job posts')}
</p>
)}
<div
className={`dropzone${dragging ? ' drag' : ''}`}
onClick={() => simulate(int(2, 4))}
onClick={() => fileInput.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
simulate(e.dataTransfer.files.length || int(2, 4))
handleFiles(e.dataTransfer.files)
}}
>
<input
ref={fileInput}
type="file"
accept=".pdf,application/pdf"
multiple
hidden
onChange={(e) => { handleFiles(e.target.files); e.target.value = '' }}
/>
<div className="dz-icn"><Icon name="upload" /></div>
<h3>Drag &amp; drop resumes here</h3>
<p className="text-muted" style={{ marginBottom: 16 }}>
or click to browse PDF, DOC, DOCX and ZIP supported · up to 20 files
or click to browse PDF only · up to 50 files per batch
</p>
<button
className="btn btn-primary"
onClick={(e) => { e.stopPropagation(); simulate(int(2, 4)) }}
onClick={(e) => { e.stopPropagation(); fileInput.current?.click() }}
>
<Icon name="upload" /> Browse Files
</button>
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 16 }}>
{['PDF', 'DOC', 'DOCX', 'ZIP'].map((t) => (
<span className="badge b-gray badge-plain" key={t}>{t}</span>
))}
<span className="badge b-gray badge-plain">PDF</span>
<span className="text-muted text-sm">DOC / DOCX support coming later</span>
</div>
</div>
<div className="flex items-center gap-8" style={{ marginTop: 16, flexWrap: 'wrap' }}>
<button className="btn btn-secondary btn-sm" onClick={() => simulate(3)}>
<Icon name="sparkles" /> Simulate 3 files
</button>
<button className="btn btn-secondary btn-sm" onClick={() => simulate(1, true)}>
<Icon name="layers" /> Simulate ZIP (8 CVs)
</button>
<span className="text-muted text-sm" style={{ marginLeft: 'auto' }}>
Files are processed locally in this demo
</span>
</div>
</div>
</div>
@ -219,12 +199,11 @@ export default function CvImport() {
<div>
<h3>Processing Queue</h3>
<span className="ch-sub">
{queue.length} file{queue.length === 1 ? '' : 's'} · {importedCount} imported
{queue.length} file{queue.length === 1 ? '' : 's'} · {scored} scored
{failed ? ` · ${failed} failed` : ''}
{selectedJob ? ` · vs ${selectedJob.title}` : ''}
</span>
</div>
<button className="btn btn-primary btn-sm" onClick={importAll}>
<Icon name="check" /> Import All
</button>
</div>
<div className="card-body">
{queue.map((i) => (
@ -233,46 +212,39 @@ export default function CvImport() {
<div style={{ flex: 1, minWidth: 0 }}>
<div className="flex items-center gap-8">
<span className="fw-600 text-sm">{i.name}</span>
{i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 7px', fontSize: 10 }}>
DUPLICATE
</span>
)}
</div>
<div className="cell-sub">{i.file} · {i.size}</div>
{i.status === 'Uploading' || i.status === 'Parsing' ? (
{i.status === 'Scoring' && (
<div className="upload-progress" style={{ marginTop: 6 }}>
<div className="upload-progress-fill" style={{ width: `${i.progress}%` }} />
</div>
) : (
<div className="cell-sub" style={{ marginTop: 4 }}>
Best match: <b>{i.job?.title}</b>
<div className="upload-progress-fill" style={{ width: '66%' }} />
</div>
)}
{i.status === 'Ready' && i.critique && (
<div className="cell-sub" style={{ marginTop: 4 }}>{i.critique}</div>
)}
{i.status === 'Failed' && (
<div className="cell-sub" style={{ marginTop: 4 }}>Could not be scored</div>
)}
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
{i.status === 'Ready' ? (
<ScoreChip score={i.atsScore} />
) : (
<Badge className={i.status === 'Parsing' ? 'b-amber' : 'b-blue'}>
{i.status}{i.status === 'Uploading' ? ` ${i.progress}%` : ''}
</Badge>
)}
{i.status === 'Ready' && <ScoreChip score={i.atsScore} />}
{i.status === 'Scoring' && <Badge className="b-blue">Scoring</Badge>}
{i.status === 'Failed' && <Badge className="b-red">{i.error}</Badge>}
</div>
<div style={{ flexShrink: 0 }}>
{i.imported ? (
<Badge className="b-green">Imported</Badge>
) : i.status === 'Ready' ? (
<button className="btn btn-primary btn-sm" onClick={() => importOne(i)}>Import</button>
) : (
<button className="act-btn" disabled><Icon name="clock" /></button>
)}
{i.status === 'Ready' && <Badge className="b-green">Saved</Badge>}
</div>
</div>
))}
</div>
</div>
)}
{queue.length === 0 && jobsQuery.isSuccess && jobs.length === 0 && (
<EmptyState icon="briefcase" title="No job posts yet">
Create a job post first resumes are always scored against a job.
</EmptyState>
)}
</div>
<div className="card" style={{ alignSelf: 'start' }}>
@ -290,44 +262,6 @@ export default function CvImport() {
</div>
</div>
</div>
{duplicateFor && (
<Modal
title="Duplicate Detected"
subtitle={duplicateFor.name}
onClose={() => setDuplicateFor(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setDuplicateFor(null)}>Cancel</button>
<button
className="btn btn-secondary"
onClick={() => { setDuplicateFor(null); toast('Merged into existing profile', 'success') }}
>
Merge
</button>
<button
className="btn btn-primary"
onClick={() => { const id = duplicateFor.id; setDuplicateFor(null); doImport(id) }}
>
Import Anyway
</button>
</>
}
>
<div className="flex gap-16 items-center">
<span className="kpi-icn i-amber" style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}>
<Icon name="users" />
</span>
<div>
<p className="fw-600" style={{ fontSize: 15 }}>A similar candidate already exists</p>
<p className="text-muted" style={{ marginTop: 4 }}>
{duplicateFor.name} matches an existing profile (95% similarity on name + email).
Importing will create a duplicate.
</p>
</div>
</div>
</Modal>
)}
</div>
)
}

View File

@ -4,30 +4,50 @@ import { useQuery } from '@tanstack/react-query'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { departments } from '../data/seed'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import { avatarColor, initials as initialsOf } from '../data/seed'
async function fetchPool() {
const res = await candidatesApi.listCandidates()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(candidatesApi.toCandidateView)
}
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
}
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
export default function TalentPool() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const poolQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchPool })
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const [q, setQ] = useState('')
const [dept, setDept] = useState('')
const [jobId, setJobId] = useState('')
// Silver medalists / passive talent candidates outside the active loop.
// Scored candidates only; failed extraction rows are noise in a talent pool.
const pool = useMemo(
() => candidates.filter((c) => ['Rejected', 'Applied', 'Hired'].includes(c.stage)),
[candidates],
() => (poolQuery.data ?? []).filter((c) => c.scoringStatus === 'completed'),
[poolQuery.data],
)
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
if (jobId && c.jobId !== jobId) return false
if (q) {
const hay = `${c.name} ${c.currentCompany ?? ''} ${c.matchedSkills.join(' ')}`.toLowerCase()
if (!hay.includes(q.toLowerCase())) return false
}
return true
}),
[pool, q, dept],
[pool, q, jobId],
)
return (
@ -35,7 +55,7 @@ export default function TalentPool() {
<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>
<p className="page-sub">{pool.length} scored candidate{pool.length === 1 ? '' : 's'} across all jobs</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => toast('Talent campaign created', 'success')}>
@ -51,50 +71,67 @@ export default function TalentPool() {
<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 className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option>
{(jobsQuery.data ?? []).map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select>
</div>
</div>
</div>
<div className="grid g-3">
{list.length === 0 ? (
<div style={{ gridColumn: '1/-1' }}>
<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={() => navigate('/candidates', { state: { openCandidate: c.id } })}
>
<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>
{poolQuery.isPending && (
<EmptyState icon="talent" title="Loading…">Fetching scored candidates from the server.</EmptyState>
)}
{poolQuery.isError && (
<EmptyState icon="talent" title="Couldnt load the talent pool">
{friendlyAuthError(poolQuery.error, 'Request failed')}
</EmptyState>
)}
{poolQuery.isSuccess && (
<div className="grid g-3">
{list.length === 0 ? (
<div style={{ gridColumn: '1/-1' }}>
<EmptyState title="No candidates found">
{pool.length === 0
? 'Score some resumes in CV Import to build the pool.'
: 'Try a different search or job filter.'}
</EmptyState>
</div>
) : (
list.map((c) => (
<div
key={c.id}
className="card"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/candidates', { state: { openCandidate: c.id } })}
>
<div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="lr-title">{c.name}</div>
<div className="lr-sub">{c.currentTitle ?? c.filename}</div>
</div>
<ScoreChip score={c.aiScore} />
</div>
<div className="k-tags" style={{ marginBottom: 12 }}>
{c.matchedSkills.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 != null ? `${c.experience} yrs` : '—'}
</span>
<span className="cell-sub">{c.currentCompany ?? '—'}</span>
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
</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>
))
)}
</div>
)}
</div>
)
}