Candidates: show ATS score, stage and recruiter instead of just an email column
CI / checks (push) Successful in 2m40s Details

The recruiter table was user-centric (GET /candidate/fetch/users), so it could
only ever render account fields - name, email, created date. Everything a
recruiter actually triages on lives on the application, not the user.

Point the table at GET /candidate/fetch and map application rows through a new
toApplicationListView, adding Job, ATS (score + band chip), Stage and Recruiter
columns. Stage and band become real filters; the dead Department facet is gone.

Manual uploads came back unscored because the list path never joined the ATS
results, so attach scores there and expose ai_score/recommendation from the
manager serializer, deriving the band from the score when the model omitted it.

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/73/head^2
Talha Ahmed 2026-09-03 20:45:35 +05:00
parent 22b2a7323f
commit 0eff43def4
5 changed files with 320 additions and 110 deletions

View File

@ -228,6 +228,11 @@ def serialize_manager_candidate(row, *, source) -> dict:
manual_id = row.get("id") if source == "manual" else None
job_post_id = row.get("assigned_job_post_id") or row.get("job_post_id")
user_id = row.get("user_id")
ats = row.get("ats_result") or {}
score = ats.get("overall_score")
band = (ats.get("band") or "").strip() or None
if score is not None and not band:
band = "Strong Match" if score >= 82 else "Potential Match" if score >= 65 else "Weak Match"
return {
"id": user_id or (f"inbox:{inbox_id}" if inbox_id is not None else f"manual:{manual_id}"),
"user_id": user_id,
@ -240,4 +245,6 @@ def serialize_manager_candidate(row, *, source) -> dict:
"manual_upload_candidate_id": str(manual_id) if manual_id else None,
"created_at": row.get("created_at"),
"source": source,
"ai_score": score,
"recommendation": band,
}

View File

@ -876,6 +876,9 @@ class CandidateView:
"assigned_job_post_id":payload.get("assigned_job_post_id"),
"job_posts":payload.get("job_posts") or [],
"assigned_job_post":payload.get("assigned_job_post"),
"job_title":payload.get("job_title"),
"recruiter":payload.get("recruiter"),
"recruiter_id":payload.get("recruiter_id"),
"source":payload.get("source"),
"file_path":payload.get("file_path"),
"ai_score":None,
@ -883,6 +886,14 @@ class CandidateView:
})
if uid:
seen.add(uid)
from inbox.plugins import get_ats_scores_for_users
owners=[p.get("user_id") for p in manual_payloads if p.get("user_id")]
ats=await get_ats_scores_for_users(self.session,owners)
for payload in manual_payloads:
row=ats.get(str(payload.get("user_id") or ""))
if row and row.get("overall_score") is not None:
payload["ai_score"]=row["overall_score"]
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
return inbox_payloads+manual_payloads
except HTTPException:
raise

View File

@ -0,0 +1,130 @@
/**
* Candidates table mapper score, stage, recruiter, rejected.
*
* node candidates-table.test.mjs
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import esbuild from 'esbuild'
const outDir = mkdtempSync(join(tmpdir(), 'tf-cand-'))
const outFile = join(outDir, 'candidates.mjs')
await esbuild.build({
entryPoints: ['src/api/candidates.js'],
outfile: outFile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
logLevel: 'error',
define: { 'import.meta.env': JSON.stringify({ VITE_API_BASE: '' }) },
})
const api = await import(pathToFileURL(outFile).href)
const { toApplicationListView } = api
let failed = 0
function ok(name, cond, extra) {
if (cond) {
console.log(`ok ${name}`)
if (extra) console.log(` ${extra}`)
} else {
failed += 1
console.log(`FAIL ${name}`)
if (extra) console.log(` ${extra}`)
}
}
const scored = toApplicationListView({
inbox_id: 41,
user_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
name: 'Ada Lovelace',
email: 'ada@example.com',
job_title: 'Backend Engineer',
recruiter: 'Sam Recruiter',
application_status: 'PENDING',
ai_score: 88,
recommendation: 'Strong Match',
created_at: '2026-09-03T10:00:00Z',
source: 'Email',
is_active: true,
})
ok('row is application-keyed', scored.id === 'inbox:41', `id=${scored.id}`)
ok('keeps userId for profile navigation', scored.userId === 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
ok('job title comes through', scored.jobTitle === 'Backend Engineer')
ok('ATS score is numeric', scored.aiScore === 88)
ok('band is Strong Match', scored.recommendation === 'Strong Match')
ok('PENDING maps to Shortlist', scored.stage === 'Shortlist', `stage=${scored.stage}`)
ok('recruiter name is kept', scored.recruiter === 'Sam Recruiter')
const rejected = toApplicationListView({
inbox_id: 42,
user_id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
name: 'Rejected Candidate',
email: 'r@example.com',
application_status: 'REJECTED',
ai_score: 40,
created_at: '2026-09-01T10:00:00Z',
})
ok('REJECTED maps to Rejected stage', rejected.stage === 'Rejected', `stage=${rejected.stage}`)
ok('CLOSED also reads as Rejected', toApplicationListView({
inbox_id: 43, application_status: 'CLOSED', name: 'Closed',
}).stage === 'Rejected')
const hired = toApplicationListView({
inbox_id: 44,
application_status: 'HIRED',
name: 'Hired Person',
ai_score: 91,
})
ok('HIRED maps to Hired', hired.stage === 'Hired')
const unscored = toApplicationListView({
manual_upload_candidate_id: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
user_id: 'dddddddd-dddd-dddd-dddd-dddddddddddd',
name: 'No Score Yet',
email: 'ns@example.com',
assigned_job_post: { title: 'Brand Manager' },
recruiter: null,
application_status: 'SCREENING',
})
ok('manual row key', unscored.id === 'manual:cccccccc-cccc-cccc-cccc-cccccccccccc')
ok('job falls back to assigned post title', unscored.jobTitle === 'Brand Manager')
ok('missing score stays null, not 0', unscored.aiScore === null)
ok('unscored has no invented band', unscored.recommendation === null)
ok('SCREENING maps to Screening', unscored.stage === 'Screening')
ok('missing recruiter is null so the table can say Unassigned', unscored.recruiter === null)
const weak = toApplicationListView({
inbox_id: 45,
name: 'Weak',
ai_score: 50,
})
ok('score without band derives Weak Match', weak.recommendation === 'Weak Match')
const potential = toApplicationListView({
inbox_id: 46,
name: 'Mid',
ai_score: 70,
})
ok('6581 derives Potential Match', potential.recommendation === 'Potential Match')
const enumStatus = toApplicationListView({
inbox_id: 47,
name: 'Enum',
application_status: { value: 'OFFER' },
})
ok('enum-shaped status still maps', enumStatus.stage === 'Offer')
rmSync(outDir, { recursive: true, force: true })
if (failed) {
console.log(`\n${failed} check(s) failed`)
process.exit(1)
}
console.log('\nAll candidates-table mapper checks passed')

View File

@ -13,7 +13,7 @@
============================================================ */
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
import { STATUS_FROM_STAGE } from './pipeline'
import { STAGE_FROM_STATUS, STATUS_FROM_STAGE } from './pipeline'
/** Active job posts for pickers. Needs job_board.view OR candidates.view.
*
@ -227,6 +227,56 @@ export function toCandidateUserView(row) {
}
}
function statusKey(value) {
if (value == null || value === '') return ''
if (typeof value === 'object' && value.value != null) return String(value.value).toUpperCase()
return String(value).toUpperCase()
}
function bandOf(score, recommendation) {
if (recommendation) return recommendation
if (score == null || !Number.isFinite(Number(score))) return null
const n = Number(score)
return n >= 82 ? 'Strong Match' : n >= 65 ? 'Potential Match' : 'Weak Match'
}
/**
* GET /candidate/fetch list row -> the Candidates table.
*
* Application-centric: score, stage, job and recruiter belong to one
* inbox/manual application, not to the user account.
*/
export function toApplicationListView(row) {
const status = statusKey(row.application_status ?? row.stage)
const name = row.name || row.email || 'Unknown'
const jobTitle = row.job_title
|| row.assigned_job_post?.title
|| (Array.isArray(row.job_posts) ? row.job_posts.find((j) => j?.title)?.title : null)
|| null
const rawScore = row.ai_score ?? row.match_score
const aiScore = rawScore == null || rawScore === '' ? null : Number(rawScore)
const score = Number.isFinite(aiScore) ? aiScore : null
return {
id: row.inbox_id != null
? `inbox:${row.inbox_id}`
: (row.manual_upload_candidate_id
? `manual:${row.manual_upload_candidate_id}`
: String(row.user_id || row.id || name)),
userId: row.user_id || null,
name,
email: row.email ?? null,
isActive: row.is_active ?? null,
jobTitle,
recruiter: row.recruiter || null,
applicationStatus: status || null,
stage: status ? (STAGE_FROM_STATUS[status] ?? 'Shortlist') : null,
source: row.source || null,
aiScore: score,
recommendation: bandOf(score, row.recommendation || null),
applied: row.created_at ? new Date(row.created_at) : (row.applied ? new Date(row.applied) : null),
}
}
/**
* Candidate profiles the `inbox -> users -> roles` join, restricted server-side
* to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).

View File

@ -1,12 +1,11 @@
/* ============================================================
Candidates the scored-candidate pool, on live backend data.
Candidates applications on live backend data.
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 or the Add Candidate modal below
both run the CV through the same persisted ATS scoring pipeline.
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
per application, so score / stage / job / recruiter have a source.
Hiring managers use GET /candidate/manager/fetch (jobs on their
requisitions). Adding a candidate still goes through CV Import or the
Add Candidate modal both run the CV through persisted ATS scoring.
============================================================ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -16,7 +15,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import DataTable, { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
@ -24,7 +23,6 @@ import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx'
import { formatRole } from '../lib/format'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
@ -33,42 +31,30 @@ import { useFormState } from '../components/AuthLayout'
import { persist, useSeedMutation } from '../data/seedQueries'
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
const EMPTY_FILTERS = { account: '', department: '' }
const EMPTY_FILTERS = { account: '', stage: '', band: '' }
const SEARCH_DEBOUNCE_MS = 300
const STAGE_FILTERS = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'On Hold', 'Rejected']
const BAND_FILTERS = ['Strong Match', 'Potential Match', 'Weak Match', 'Unscored']
const BAND_BADGE = {
'Strong Match': 'b-green',
'Potential Match': 'b-amber',
'Weak Match': 'b-gray',
}
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
/** Seeded `candidate` role is id 8; id 4 is hiring_manager (signup default). */
const CANDIDATE_ROLE_ID = 8
/* Rows are candidate USER accounts (GET /candidate/fetch/users?role_id=8), not
rows of the scored `candidates` table.
Why: /candidate/scored/fetch only ever returns CVs that have been through the
ATS, so the pool was empty for every candidate who has an account but no score
yet. The user list is the real population; the score is an attribute some of
them have.
The consequence is that the ATS columns have no source on this screen see
toCandidateUserView. Open a candidate to get their score, which the shared
Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */
async function fetchCandidates({ top = DEFAULT_PAGE_SIZE, skip = 0 } = {}) {
const [usersRes, appsRes] = await Promise.all([
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip }),
candidatesApi.list({ limit: 100 }).catch(() => null),
])
const rows = Array.isArray(usersRes?.data) ? usersRes.data : []
const sourceByUser = new Map()
for (const app of Array.isArray(appsRes?.data) ? appsRes.data : []) {
const uid = app.user_id
if (!uid || sourceByUser.has(uid)) continue
if (app.source) sourceByUser.set(String(uid), app.source)
}
return rows.map((row) => {
const view = candidatesApi.toCandidateUserView(row)
const source = sourceByUser.get(String(view.userId))
return source ? { ...view, source } : view
async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search } = {}) {
const res = await candidatesApi.list({
limit,
offset,
search: search || undefined,
})
const rows = Array.isArray(res?.data) ? res.data : []
return {
rows: rows.map(candidatesApi.toApplicationListView),
total: Number(res?.total ?? rows.length) || 0,
}
}
async function fetchJobs() {
@ -78,10 +64,31 @@ async function fetchJobs() {
}
function recommendationOf(c) {
if (c.aiScore == null) return 'Weak Match'
if (c.recommendation) return c.recommendation
if (c.aiScore == null) return null
return c.aiScore >= 82 ? 'Strong Match' : c.aiScore >= 65 ? 'Potential Match' : 'Weak Match'
}
function stageOf(status) {
const key = String(status || '').toUpperCase()
return pipelineApi.STAGE_FROM_STATUS[key] ?? (key ? 'Shortlist' : null)
}
function AtsCell({ score, recommendation }) {
if (score == null) return <span className="text-muted">Not scored</span>
const band = recommendation || recommendationOf({ aiScore: score })
return (
<div>
<ScoreChip score={score} />
{band && (
<div className="cell-sub">
<Badge className={BAND_BADGE[band] || 'b-gray'}>{band}</Badge>
</div>
)}
</div>
)
}
/* Client-side guard only the route has no size cap of its own, so this just
stops an obviously wrong file from being read into memory and posted. */
const MAX_CV_MB = 10
@ -175,14 +182,20 @@ function HiringManagerCandidates() {
sortValue: (r) => r.job_title || '',
render: (r) => r.job_title || '—',
},
{
key: 'score',
label: 'ATS',
sortable: true,
sortValue: (r) => (r.ai_score == null ? -1 : Number(r.ai_score)),
render: (r) => <AtsCell score={r.ai_score} recommendation={r.recommendation} />,
},
{
key: 'stage',
label: 'Stage',
sortable: true,
sortValue: (r) => r.application_status || '',
render: (r) => {
const status = String(r.application_status || '').toUpperCase()
const stage = pipelineApi.STAGE_FROM_STATUS[status] ?? 'Shortlist'
const stage = stageOf(r.application_status) || 'Shortlist'
return <Badge className={STAGE_BADGE[stage] || ''}>{stage}</Badge>
},
},
@ -245,6 +258,7 @@ function RecruiterCandidates() {
const updateCandidates = useSeedMutation('candidates')
const [q, setQ] = useState('')
const [search, setSearch] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('recent')
@ -254,27 +268,18 @@ function RecruiterCandidates() {
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
const countQuery = useQuery({
queryKey: qk.candidates.count({ roleId: CANDIDATE_ROLE_ID }),
queryFn: async () => {
const res = await candidatesApi.countCandidateUsers({ roleId: CANDIDATE_ROLE_ID })
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
},
staleTime: Infinity,
})
useEffect(() => {
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(t)
}, [q])
useEffect(() => { setSkip(0) }, [search])
const candidatesQuery = useQuery({
queryKey: qk.candidates.list({ top: pageSize, skip }),
queryFn: () => fetchCandidates({ top: pageSize, skip }),
queryKey: qk.candidates.list({ limit: pageSize, offset: skip, search }),
queryFn: () => fetchCandidates({ limit: pageSize, offset: skip, search }),
})
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const deptsQuery = useQuery({
queryKey: qk.jobPosts.departments(),
queryFn: async () => {
const res = await jobPostsApi.listDepartments()
return Array.isArray(res?.data) ? res.data : []
},
})
const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data])
const candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data])
const jobsById = useMemo(
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
[jobsQuery.data],
@ -287,7 +292,7 @@ function RecruiterCandidates() {
})
const jobTitleOf = useCallback(
(c) => jobsById[c.jobId]?.title ?? '—',
(c) => c.jobTitle || jobsById[c.jobId]?.title || '—',
[jobsById],
)
@ -301,9 +306,6 @@ function RecruiterCandidates() {
})
const atsScore = scoreQuery.data?.overall_score ?? null
/* The relevance blend (score + matched-skill ratio + recency) went with the
scoring columns none of its three inputs exists on a users row. */
const openProfile = useCallback(
(c) => {
qc.setQueryData(qk.seed.recentlyViewed(), (old = []) => {
@ -313,7 +315,7 @@ function RecruiterCandidates() {
})
// Real candidates get the full profile PAGE; the modal stays only as the
// fallback for rows without a user account.
const uid = c.userId || c.id
const uid = c.userId
if (uid) navigate(`/candidate/${uid}`)
else setProfileFor(c)
},
@ -333,39 +335,35 @@ function RecruiterCandidates() {
let list = candidates.filter((c) => {
if (f.account === 'Active' && !c.isActive) return false
if (f.account === 'Unconfirmed' && c.isActive) return false
if (q) {
// Client-side: the route accepts `search` but never forwards it to the
// service layer, so asking the server to filter would be a silent no-op.
const term = q.toLowerCase()
const hay = [
c.name, c.email ?? '', c.filename ?? '', c.currentTitle ?? '',
c.currentCompany ?? '', c.matchedSkills.join(' '),
].join(' ').toLowerCase()
if (!hay.includes(term)) return false
}
if (f.stage && (c.stage || '') !== f.stage) return false
if (f.band === 'Unscored' && c.aiScore != null) return false
if (f.band && f.band !== 'Unscored' && recommendationOf(c) !== f.band) return false
return true
})
if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
else if (sortMode === 'score') {
list = [...list].sort((a, b) => (b.aiScore ?? -1) - (a.aiScore ?? -1))
} else {
list = [...list].sort((a, b) => (b.applied?.getTime() ?? 0) - (a.applied?.getTime() ?? 0))
}
return list
}, [candidates, filters, q, sortMode])
}, [candidates, filters, sortMode])
/* Columns follow the row source. A `users` row carries identity only, so the
four scoring columns (Scored For / Exp / Relevance / ATS) have nothing to
read and are gone rather than rendered as permanent em-dashes the same
rule the Inbox screen set and this file's header states. They come back the
moment the rows carry a score again. */
const columns = useMemo(
() => [
{ key: 'name', label: 'Candidate', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'jobTitle', label: 'Job', sortable: true },
{ key: 'aiScore', label: 'ATS', sortable: true },
{ key: 'stage', label: 'Stage', sortable: true },
{ key: 'recruiter', label: 'Recruiter', sortable: true },
{ key: 'applied', label: 'Added', sortable: true },
],
[],
)
const t = useDataTable({ columns, rows, pageSize: Math.max(rows.length, 1) })
const total = countQuery.data ?? 0
const total = candidatesQuery.data?.total ?? 0
const pages = Math.max(1, Math.ceil(total / pageSize))
const from = total ? skip + 1 : 0
const to = total ? skip + rows.length : 0
@ -381,13 +379,11 @@ function RecruiterCandidates() {
.map((id) => candidates.find((c) => c.id === id))
.filter(Boolean)
const setFilter = (k, v) => setFilters((f) => ({ ...f, [k]: v }))
const setFilter = (k, v) => {
setFilters((f) => ({ ...f, [k]: v }))
setSkip(0)
}
/* The gate is the ACCOUNT, not `scoringStatus`. Rows here are `users` rows and
toCandidateUserView leaves scoringStatus null by construction, so checking it
rejected every candidate on the screen and the ATS Match button only ever
toasted. Whether a score exists is the modal's own question it resolves
that from ats_results, which the row cannot know about. */
function openAts(c) {
if (!c.userId) {
toast('This candidate has no account to look a score up against', 'info')
@ -438,7 +434,7 @@ function RecruiterCandidates() {
<div className="page">
<PageHeader
title="Candidates"
sub={<>{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}</>}
sub={<>{total} application{total === 1 ? '' : 's'}</>}
actions={<>
<button
className="btn btn-secondary"
@ -451,20 +447,26 @@ function RecruiterCandidates() {
await exportStyledXlsx({
filename: `candidates-${new Date().toISOString().slice(0, 10)}`,
title: 'Candidates',
subtitle: `${rows.length} candidate account${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
subtitle: `${rows.length} application${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
columns: [
{ header: 'Name', key: 'name', width: 26 },
{ header: 'Email', key: 'email', width: 30 },
{ header: 'Role', key: 'role', width: 22 },
{ header: 'Applied', key: 'applied', width: 12 },
{ header: 'Source', key: 'source', width: 14 },
{ header: 'Account', key: 'account', width: 12 },
{ header: 'Job', key: 'job', width: 28 },
{ header: 'ATS', key: 'score', width: 10 },
{ header: 'Band', key: 'band', width: 16 },
{ header: 'Stage', key: 'stage', width: 14 },
{ header: 'Recruiter', key: 'recruiter', width: 22 },
{ header: 'Added', key: 'applied', width: 12 },
],
rows: rows.map((c) => ({
name: c.name, email: c.email, role: formatRole(c.roleName),
name: c.name,
email: c.email,
job: c.jobTitle || '',
score: c.aiScore ?? '',
band: recommendationOf(c) || '',
stage: c.stage || '',
recruiter: c.recruiter || '',
applied: c.applied ? c.applied.toLocaleDateString() : '',
source: c.source,
account: c.isActive ? 'Active' : 'Unconfirmed',
})),
})
toast(`Exported ${rows.length} candidate${rows.length === 1 ? '' : 's'}`, 'success')
@ -500,7 +502,7 @@ function RecruiterCandidates() {
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name or email…" />
</div>
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
<Icon name="filter" /> Filters
@ -512,6 +514,7 @@ function RecruiterCandidates() {
<label className="text-muted text-sm">Sort:</label>
<select className="select" value={sortMode} onChange={(e) => setSortMode(e.target.value)}>
<option value="recent">Most Recent</option>
<option value="score">Highest ATS</option>
<option value="name">Name AZ</option>
</select>
</div>
@ -522,11 +525,9 @@ function RecruiterCandidates() {
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
{/* Job / Matched Skill / Source / ATS Score / scoring Status are gone
with the scoring columns: on a users row every one of them would
match nothing and silently empty the table. */}
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} />
<Facet label="Department" value={filters.department} onChange={(v) => setFilter('department', v)} any="All Departments" options={deptsQuery.data ?? []} />
<Facet label="Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any stage" options={STAGE_FILTERS} />
<Facet label="ATS band" value={filters.band} onChange={(v) => setFilter('band', v)} any="Any band" options={BAND_FILTERS} />
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any account" options={['Active', 'Unconfirmed']} />
</div>
)}
</div>
@ -553,8 +554,8 @@ function RecruiterCandidates() {
{t.pageRows.length === 0 ? (
<tr>
<td colSpan={columns.length}>
<EmptyState title="No candidates yet">
Score resumes in CV Import to fill this table.
<EmptyState title="No applications here">
Import a CV or add a candidate to see score, stage, and recruiter on this table.
</EmptyState>
</td>
</tr>
@ -575,12 +576,23 @@ function RecruiterCandidates() {
<Badge className="b-gray" style={{ marginLeft: 6, fontSize: 10 }}>Form</Badge>
)}
</div>
<div className="cell-sub">{formatRole(c.roleName) || '—'}</div>
<div className="cell-sub">{c.email || '—'}</div>
</div>
</div>
</td>
<td>
<span className="text-sm cell-clip" title={c.email || undefined}>{c.email ?? '—'}</span>
<span className="text-sm cell-clip" title={c.jobTitle || undefined}>{c.jobTitle || '—'}</span>
</td>
<td>
<AtsCell score={c.aiScore} recommendation={recommendationOf(c)} />
</td>
<td>
{c.stage
? <Badge className={STAGE_BADGE[c.stage] || ''}>{c.stage}</Badge>
: <span className="text-muted"></span>}
</td>
<td>
<span className="text-sm">{c.recruiter || 'Unassigned'}</span>
</td>
<td>
<span className="text-sm">