diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index d2d4c58..8900e6e 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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, } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 193f40d..1322b17 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -874,6 +874,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, @@ -881,6 +884,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"]) data=inbox_payloads+manual_payloads if assigned_job_post_id: job_id=str(assigned_job_post_id) diff --git a/frontend/candidates-table.test.mjs b/frontend/candidates-table.test.mjs new file mode 100644 index 0000000..afdb05b --- /dev/null +++ b/frontend/candidates-table.test.mjs @@ -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') diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 29253b3..b4d8c96 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -14,7 +14,7 @@ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient' import { toDate } from '../lib/format' -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. * @@ -229,6 +229,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). diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 3ec14f5..a914631 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -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,56 +31,37 @@ 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 APPLICATIONS (GET /candidate/fetch), not candidate user accounts. -/* 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, assignedJobPostId } = {}) { - const [usersRes, appsRes] = await Promise.all([ - candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip, assignedJobPostId }), - candidatesApi.list({ limit: 100, assignedJobPostId }).catch(() => null), - ]) - const rows = Array.isArray(usersRes?.data) ? usersRes.data : [] - const sourceByUser = new Map() - const jobsByUser = new Map() - for (const app of Array.isArray(appsRes?.data) ? appsRes.data : []) { - const uid = app.user_id - if (!uid) continue - const key = String(uid) - if (app.source && !sourceByUser.has(key)) sourceByUser.set(key, app.source) - const ids = jobsByUser.get(key) ?? [] - for (const id of candidatesApi.jobIdsOf(app)) { - if (!ids.includes(id)) ids.push(id) - } - jobsByUser.set(key, ids) - } - return rows.map((row) => { - const view = candidatesApi.toCandidateUserView(row) - const key = String(view.userId) - const source = sourceByUser.get(key) - const jobIds = jobsByUser.get(key) ?? [] - return { - ...view, - source: source || view.source, - jobIds, - jobId: jobIds[0] ?? null, - } + The user list (/candidate/fetch/users) is the wider population, but score, + stage, job and recruiter all hang off the application — on a users row those + columns have no source at all. One row per application is what a recruiter + triages on, so the table follows the application. */ +async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search, assignedJobPostId } = {}) { + const res = await candidatesApi.list({ + limit, + offset, + search: search || undefined, + assignedJobPostId: assignedJobPostId || 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() { @@ -97,10 +76,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 Not scored + const band = recommendation || recommendationOf({ aiScore: score }) + return ( +
+ + {band && ( +
+ {band} +
+ )} +
+ ) +} + /* 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 @@ -208,14 +208,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) => , + }, { 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 {stage} }, }, @@ -289,6 +295,7 @@ function RecruiterCandidates() { const [q, setQ] = useState('') const [jobId, setJobId] = useState('') + const [search, setSearch] = useState('') const [filters, setFilters] = useState(EMPTY_FILTERS) const [showFilters, setShowFilters] = useState(false) const [sortMode, setSortMode] = useState('recent') @@ -298,29 +305,28 @@ function RecruiterCandidates() { const [atsFor, setAtsFor] = useState(null) const [adding, setAdding] = useState(false) - const countQuery = useQuery({ - queryKey: qk.candidates.count({ roleId: CANDIDATE_ROLE_ID, assignedJobPostId: jobId || undefined }), - queryFn: async () => { - const res = await candidatesApi.countCandidateUsers({ - roleId: CANDIDATE_ROLE_ID, - assignedJobPostId: jobId || undefined, - }) - return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0) - }, - }) + 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, assignedJobPostId: jobId || undefined }), - queryFn: () => fetchCandidates({ top: pageSize, skip, assignedJobPostId: jobId || undefined }), + queryKey: qk.candidates.list({ + limit: pageSize, + offset: skip, + search, + assignedJobPostId: jobId || undefined, + }), + queryFn: () => fetchCandidates({ + limit: pageSize, + offset: skip, + search, + assignedJobPostId: jobId || undefined, + }), }) 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], @@ -333,7 +339,7 @@ function RecruiterCandidates() { }) const jobTitleOf = useCallback( - (c) => jobsById[c.jobId]?.title ?? '—', + (c) => c.jobTitle || jobsById[c.jobId]?.title || '—', [jobsById], ) @@ -347,9 +353,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 = []) => { @@ -359,7 +362,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) }, @@ -379,39 +382,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 @@ -427,13 +426,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') @@ -484,7 +481,7 @@ function RecruiterCandidates() {
{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}} + sub={<>{total} application{total === 1 ? '' : 's'}} actions={<>
)} @@ -613,10 +616,10 @@ function RecruiterCandidates() { {t.pageRows.length === 0 ? ( - + {candidates.length - ? 'Try a different search or job filter.' - : 'Score resumes in CV Import to fill this table.'} + ? 'Try a different search, job, stage, or band filter.' + : 'Import a CV or add a candidate to see score, stage, and recruiter on this table.'} @@ -637,12 +640,23 @@ function RecruiterCandidates() { Form )} -
{formatRole(c.roleName) || '—'}
+
{c.email || '—'}
- {c.email ?? '—'} + {c.jobTitle || '—'} + + + + + + {c.stage + ? {c.stage} + : } + + + {c.recruiter || 'Unassigned'}