Candidates: show ATS score, stage and recruiter instead of just an email column
CI / checks (push) Successful in 2m40s
Details
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
parent
22b2a7323f
commit
0eff43def4
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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('65–81 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')
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 A–Z</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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue