HR-ATS-Portal/frontend/src/screens/Candidates.jsx

1327 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
Candidates — applications on live backend data.
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
per application, so score / stage / job / recruiter have a source.
Without candidates.manage the server scopes that list to jobs the user
owns as recruiter (or created). Tick Requisitions → Configure in Access
Control to see candidates on jobs opened from that user's requisitions;
recruiter assignment on the job does not hide them. Hiring managers use GET
/candidate/manager/fetch (same job chain). 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'
import { useLocation, useNavigate } from 'react-router-dom'
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, ScoreChip, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager, seesAllCandidates, scopesToOwnRequisitions } from '../auth/permissions'
import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline'
import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory'
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: '', 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']
/* Rows are APPLICATIONS (GET /candidate/fetch), not candidate user accounts.
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({ createdBy } = {}) {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows
.filter((row) => row && row.id != null)
.filter((row) => !createdBy || String(row.created_by || '') === String(createdBy))
.map((row) => ({
id: String(row.id),
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
}))
}
function recommendationOf(c) {
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
/** Live job posts offered by the Add Candidate picker. */
const JOB_POST_LIMIT = 100
/* Referral By must name a colleague, so it is constrained to a company address:
a referral from outside the company is not a referral, and a bare name
("Sarah") cannot be resolved to a person later.
This is the ONLY place the rule lives. `referral_by` is a free-text column and
the route does not check it, so anything posted outside this form is stored
as-is — the constraint is a data-entry guard, not an invariant. */
const REFERRAL_DOMAIN = 'utopiabrands.com'
const REFERRAL_RE = new RegExp(
`^[a-z0-9][a-z0-9._%+-]*@${REFERRAL_DOMAIN.replace(/\./g, '\\.')}$`,
'i',
)
/**
* The one reading of the Referral By box: surrounding whitespace is stripped, so
* a field holding only spaces is absent rather than invalid, and the address is
* lower-cased so " Ada@UtopiaBrands.com " and "ada@utopiabrands.com" are stored
* as one referrer rather than two.
*/
const referralValue = (raw) => (raw || '').trim().toLowerCase()
const STAGE_BADGE = {
Shortlist: 'b-indigo',
Screening: 'b-teal',
Assessment: 'b-purple',
Interview: 'b-amber',
Offer: 'b-green',
Approved: 'b-green',
Hired: 'b-green',
'On Hold': 'b-amber',
Rejected: 'b-gray',
}
export default function Candidates() {
const { user } = useAuth()
if (isHiringManager(user)) return <HiringManagerCandidates />
return <RecruiterCandidates />
}
function HiringManagerCandidates() {
const navigate = useNavigate()
const location = useLocation()
const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
useEffect(() => {
const id = location.state?.openCandidate
if (id) navigate(`/candidate/${id}`, { replace: true })
}, [location.state, navigate])
const listQuery = useQuery({
queryKey: qk.candidates.managerList(),
queryFn: async () => {
const res = await candidatesApi.listForManager({ limit: 200, offset: 0 })
return Array.isArray(res?.data) ? res.data : []
},
})
const rowsAll = listQuery.data ?? []
const jobs = useMemo(() => {
const seen = new Set()
const out = []
for (const r of rowsAll) {
const id = r.job_post_id
if (id == null || seen.has(String(id))) continue
seen.add(String(id))
out.push({ id: String(id), title: r.job_title || 'Untitled' })
}
out.sort((a, b) => a.title.localeCompare(b.title))
return out
}, [rowsAll])
const rows = useMemo(() => {
const needle = q.trim().toLowerCase()
return rowsAll.filter((r) => {
if (jobId && String(r.job_post_id) !== jobId) return false
if (!needle) return true
const hay = [r.name, r.email, r.job_title].filter(Boolean).join(' ').toLowerCase()
return hay.includes(needle)
})
}, [rowsAll, q, jobId])
const columns = [
{
key: 'name',
label: 'Candidate',
sortable: true,
sortValue: (r) => r.name || '',
render: (r) => (
<>
<div className="cell-primary">
{r.name || '—'}
<ReappliedBadge row={r} />
</div>
<div className="cell-sub">{r.email || '—'}</div>
</>
),
},
{
key: 'job',
label: 'Job',
sortable: true,
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 stage = stageOf(r.application_status) || 'Shortlist'
return <Badge className={STAGE_BADGE[stage] || ''}>{stage}</Badge>
},
},
{
key: 'applied',
label: 'Allocated',
sortable: true,
sortValue: (r) => r.created_at || '',
render: (r) => (
<span className="text-muted">
{r.created_at ? fmtDate(r.created_at) : '—'}
</span>
),
},
]
return (
<div className="page">
<PageHeader
title="Candidates"
sub={`${rowsAll.length} candidate${rowsAll.length === 1 ? '' : 's'} on your requisition jobs`}
/>
<div className="card">
<div className="card-body">
<div className="toolbar" style={{ marginBottom: 14 }}>
<div className="toolbar-search" style={{ flex: 1, maxWidth: 360 }}>
<Icon name="search" />
<input
placeholder="Search name, email, or job…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option>
{jobs.map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
</div>
{listQuery.isPending && <SkeletonRows rows={4} />}
{listQuery.isError && (
<EmptyState icon="users" title="Couldnt load candidates">
{friendlyAuthError(listQuery.error, 'Please try again.')}
</EmptyState>
)}
{listQuery.isSuccess && (
<DataTable
columns={columns}
rows={rows}
empty={
jobId || q
? 'No candidates match these filters.'
: 'No candidates are allocated to jobs opened from your requisitions yet.'
}
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
/>
)}
</div>
</div>
</div>
)
}
function RecruiterCandidates() {
const { toast } = useToast()
const qc = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const { user } = useAuth()
const updateCandidates = useSeedMutation('candidates')
const unscoped = seesAllCandidates(user)
const requisitionScoped = scopesToOwnRequisitions(user)
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')
const [skip, setSkip] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
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({
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({
createdBy: unscoped ? 'all' : requisitionScoped ? 'requisition' : (user?.id ?? null),
}),
queryFn: () => fetchJobs({
createdBy: unscoped || requisitionScoped ? undefined : user?.id,
}),
})
const candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [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 jobTitleOf = useCallback(
(c) => c.jobTitle || jobsById[c.jobId]?.title || '—',
[jobsById],
)
/* Same click-time score fetch Talent Pool uses: GET /pipeline/candidate/score/fetch
only while the profile modal is open, cached per userId. */
const scoreQuery = useQuery({
queryKey: qk.pipeline.candidateScore({ userId: profileFor?.userId ?? null }),
queryFn: () => pipelineApi.fetchCandidateScore({ userId: profileFor.userId }),
select: pipelineApi.toAtsScore,
enabled: Boolean(profileFor?.userId),
})
const atsScore = scoreQuery.data?.overall_score ?? null
const openProfile = useCallback(
(c) => {
qc.setQueryData(qk.seed.recentlyViewed(), (old = []) => {
const next = [c.id, ...old.filter((id) => id !== c.id)].slice(0, 12)
persist('tf-recent', next)
return next
})
// Real candidates get the full profile PAGE; the modal stays only as the
// fallback for rows without a user account.
const uid = c.userId
if (uid) navigate(`/candidate/${uid}`)
else setProfileFor(c)
},
[qc, navigate],
)
// Deep links from Talent Pool, global search, dashboard…
useEffect(() => {
const st = location.state
if (!st) return
if (st.openAdd) setAdding(true)
if (st.openCandidate) navigate(`/candidate/${st.openCandidate}`, { replace: true })
}, [location.state, navigate])
const rows = useMemo(() => {
const f = filters
let list = candidates.filter((c) => {
if (f.account === 'Active' && !c.isActive) return false
if (f.account === 'Unconfirmed' && c.isActive) 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, sortMode])
const columns = useMemo(
() => [
{ key: 'name', label: 'Candidate', 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 = 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
const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages)
useEffect(() => {
if (total <= 0 || skip < total) return
setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
}, [total, pageSize, skip])
const recentChips = recentlyViewed
.slice(0, 6)
.map((id) => candidates.find((c) => c.id === id))
.filter(Boolean)
const setFilter = (k, v) => {
setFilters((f) => ({ ...f, [k]: v }))
setSkip(0)
}
function openAts(c) {
if (!c.userId) {
toast('This candidate has no account to look a score up against', 'info')
return
}
setAtsFor(c)
}
function toggleFav(c) {
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success')
}
function advance(c) {
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) {
toast(`${c.name} cannot be advanced further`, 'warning')
return
}
const stage = STAGE_ORDER[i + 1]
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x)))
setProfileFor((p) => (p && p.id === c.id ? { ...p, stage, status: stage } : p))
toast(`${c.name} moved to ${stage}`, 'success')
}
/* After a manual add, the CV goes through the same persisted scoring pipeline
CV Import and the profile ATS match use (POST /candidate/score): the score
lands in the scored `candidates` table, and re-uploading the same bytes
against the same job updates that row rather than duplicating it. The
mutation lives here, not in AddCandidate, because the modal closes on save
and an unmounted component's mutation callbacks never fire. */
const scoreCv = useMutation({
mutationFn: ({ jobPostId, file }) => candidatesApi.scoreUploads(jobPostId, [file]),
onSuccess: (res) => {
const row = Array.isArray(res?.data) ? res.data[0] : null
if (row?.status === 'completed') {
toast(`CV scored ${row.match_score}/100 against the applied job — saved to the pool`, 'success')
} else {
toast(`CV could not be scored${row?.error_code ? `${row.error_code}` : ''}`, 'warning')
}
qc.invalidateQueries({ queryKey: qk.candidates.all() })
},
onError: (err) => toast(friendlyAuthError(err, 'Candidate saved, but CV scoring failed'), 'error'),
})
return (
<div className="page">
<PageHeader
title="Candidates"
sub={<>{total} application{total === 1 ? '' : 's'}</>}
actions={<>
<button
className="btn btn-secondary"
onClick={async () => {
if (!rows.length) {
toast('Nothing to export — current filters match no candidates', 'warning')
return
}
try {
await exportStyledXlsx({
filename: `candidates-${new Date().toISOString().slice(0, 10)}`,
title: 'Candidates',
subtitle: `${rows.length} application${rows.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
columns: [
{ header: 'Name', key: 'name', width: 26 },
{ header: 'Email', key: 'email', width: 30 },
{ 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,
job: c.jobTitle || '',
score: c.aiScore ?? '',
band: recommendationOf(c) || '',
stage: c.stage || '',
recruiter: c.recruiter || '',
applied: c.applied ? fmtDate(c.applied) : '',
})),
})
toast(`Exported ${rows.length} candidate${rows.length === 1 ? '' : 's'}`, 'success')
} catch {
toast('Export failed', 'error')
}
}}
>
<Icon name="download" /> Export
</button>
<button className="btn btn-secondary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Import CVs
</button>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<Icon name="plus" /> Add Candidate
</button>
</>}
/>
{recentChips.length > 0 && (
<div className="flex items-center gap-8 flex-wrap mb-12">
<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={initialsOf(c.name)} color={avatarColor(c.name)} /> {c.name.split(' ')[0]}
</button>
))}
</div>
)}
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name or email…" />
</div>
<select
className="select"
value={jobId}
onChange={(e) => {
setJobId(e.target.value)
setSkip(0)
}}
>
<option value="">All Jobs</option>
{(jobsQuery.data ?? []).map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
<Icon name="filter" /> Filters
</button>
<div className="spacer" />
{/* One flex group so the label and select wrap together on phones
instead of stranding "Sort:" at the end of the previous row. */}
<div className="flex items-center gap-8">
<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>
</div>
{showFilters && (
<div
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
{/* Job stays a toolbar dropdown, sent as assigned_job_post_id on
GET /candidate/fetch. */}
<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>
{candidatesQuery.isPending && (
<div className="card-body">
<SkeletonRows rows={6} />
</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">
<DataTableHead columns={columns} sort={t.sort} toggleSort={t.toggleSort} />
<tbody>
{t.pageRows.length === 0 ? (
<tr>
<td colSpan={columns.length}>
<EmptyState title={candidates.length ? 'No matches' : 'No applications yet'}>
{candidates.length
? 'Try a different search, job, stage, or band filter.'
: unscoped
? 'Import a CV or add a candidate to see score, stage, and recruiter on this table.'
: requisitionScoped
? 'Candidates appear here when they are allocated to jobs opened from your requisitions.'
: 'Candidates appear here when they are allocated to a job you created.'}
</EmptyState>
</td>
</tr>
) : (
t.pageRows.map((c) => (
<tr
key={c.id}
className="row-click"
onClick={() => openProfile(c)}
>
<td>
<div className="user-cell">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} />
<div>
<div className="cell-primary">
{c.name}
{c.source === 'Form' && (
<Badge className="b-gray" style={{ marginLeft: 6, fontSize: 10 }}>Form</Badge>
)}
<ReappliedBadge row={c} />
</div>
<div className="cell-sub">{c.email || '—'}</div>
</div>
</div>
</td>
<td>
<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">
{c.applied ? fmtDate(c.applied) : '—'}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
from={from}
to={to}
total={total}
page={currentPage}
pages={pages}
setPage={(p) => setSkip((p - 1) * pageSize)}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }}
pageSizeMax={500}
/>
</div>
)}
</div>
{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),
initials: initialsOf(profileFor.name),
color: avatarColor(profileFor.name),
stage: profileFor.stage || 'Shortlist',
userId: profileFor.userId || profileFor.id,
}}
atsScore={atsScore}
recommendation={scoreQuery.data?.band ?? null}
onClose={() => setProfileFor(null)}
onAdvance={advance}
onToggleFav={toggleFav}
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
/>
)}
{adding && (
<AddCandidate
onClose={() => setAdding(false)}
onSave={({ jobPostId, file } = {}) => {
setAdding(false)
toast('Candidate added to pipeline', 'success')
if (jobPostId && file) scoreCv.mutate({ jobPostId, file })
}}
onInvalid={() => toast('Please fix the highlighted fields', 'error')}
/>
)}
</div>
)
}
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} value={o}>{labels?.[o] ?? o}</option>)}
</select>
</div>
)
}
const asList = (value) => (Array.isArray(value) ? value : [])
/** ISO stamp -> display date; ats_results.computed_at is a string, fmtDate takes a Date. */
function fmtStamp(value) {
return fmtDate(value) || null
}
/**
* The whole ATS result for one candidate, assembled from the two places it lives.
*
* It CANNOT render from the `candidate` prop on this screen: a row here is a
* `users` account and toCandidateUserView leaves score, keywords and critique
* null by construction, so the modal used to paint an empty shell. It reads the
* same two sources ScoredCandidateProfile does, under the same query keys, so
* opening it from that profile is a cache hit rather than two more requests:
*
* - ats_results (GET /pipeline/candidate/score/fetch) — overall_score, band,
* the job post the score was computed against, and when.
* - the detail payload (GET /candidate/fetch?user_id=) — matched/missing
* keywords and the critique, which the route resolves off the scored
* `candidates` row: by candidate_id, or by email+job when the CV's address
* matched a user account and candidate_id is therefore NULL
* (backend/job/candidate/views.py:782-792).
*
* The prop is the last fallback, for callers whose rows already carry a score
* (the scored leaderboard).
*/
export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
const userId = c.userId ?? null
const detail = useQuery({
queryKey: qk.candidates.detail(userId),
queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null,
enabled: Boolean(userId),
})
const ats = useQuery({
queryKey: qk.pipeline.candidateScore({ userId }),
queryFn: () => pipelineApi.fetchCandidateScore({ userId }),
select: pipelineApi.toAtsScore,
enabled: Boolean(userId),
})
const { data: jobTitles } = useJobTitles()
const live = detail.data ?? null
const row = ats.data ?? null
// ats_results wins over the detail payload's denormalised copy, because it is
// the row the copy is made from; the prop is the fallback for rows that came
// from the scored leaderboard already carrying one.
const score = row?.overall_score ?? live?.ai_score ?? c.aiScore ?? null
const matched = asList(live?.matched_keywords).length
? asList(live.matched_keywords) : asList(c.matchedSkills)
const missing = asList(live?.missing_keywords).length
? asList(live.missing_keywords) : asList(c.missingSkills)
const critique = live?.summary_critique ?? c.critique ?? null
const scoredJobId = row?.job_post_id ?? live?.scored_job_post_id ?? null
const against = (scoredJobId && jobTitles?.get(String(scoredJobId)))
|| live?.job_title
|| (jobTitle && jobTitle !== '—' ? jobTitle : null)
const scoredOn = fmtStamp(row?.computed_at ?? live?.scored_at)
const recommendation = row?.band || live?.recommendation || recommendationOf({ aiScore: score })
const recCls = recommendation === 'Strong Match' ? 'recc-strong'
: recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
const ringColor = score >= 82 ? 'var(--success)' : score >= 65 ? 'var(--warning)' : 'var(--danger)'
const pending = Boolean(userId) && (ats.isPending || detail.isPending)
return (
<Modal
title="ATS Match Analysis"
subtitle={against ?? jobTitle}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-primary" onClick={() => onProfile(c)}>View Full Profile</button>
</>
}
>
{pending ? (
<EmptyState icon="refresh" title="Loading match analysis…">
Fetching the ATS result.
</EmptyState>
) : score == null ? (
<EmptyState icon="target" title="Not scored yet">
{ats.isError
? friendlyAuthError(ats.error, 'The ATS result could not be loaded.')
: 'This candidate has not been scored against a job post.'}
</EmptyState>
) : (<>
<div className={`recc-banner ${recCls}`}>
<span className="recc-icn">
<Icon name={recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
</span>
<div style={{ flex: 1 }}>
<div className="fw-600" style={{ fontSize: 15 }}>{recommendation}</div>
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name}{against ? ` for ${against}` : ''}</div>
</div>
</div>
<div className="grid g-2" style={{ alignItems: 'center', marginBottom: 20 }}>
<div style={{ textAlign: 'center' }}>
{/* overall_score is a float column; the ring and the number want an int. */}
<div className="ats-ring" style={{ '--pct': Math.round(score), '--c': ringColor }}>
<div className="ats-val">
<div className="ats-num">{Math.round(score)}</div>
<div className="ats-lbl">ATS MATCH</div>
</div>
</div>
</div>
<div>
<h3 className="form-section-title" style={{ marginTop: 0 }}>Assessment</h3>
<p className="text-muted" style={{ fontSize: 13 }}>{critique ?? '—'}</p>
</div>
</div>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Scored Against</div><div className="iv">{against ?? ''}</div></div>
<div className="info-item"><div className="il">Scored On</div><div className="iv">{scoredOn ?? ''}</div></div>
</div>
<h3 className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({matched.length})
</h3>
<div className="k-tags" style={{ marginBottom: 16 }}>
{matched.length
? matched.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
<h3 className="form-section-title" style={{ marginTop: 0 }}>
Missing Skills ({missing.length})
</h3>
<div className="k-tags">
{missing.length
? missing.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
<div className="divider" />
<p className="text-muted text-sm">
<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>
)
}
/**
* One selectable role, drawn the way Job Matching draws its suggested roles
* (Matching.jsx::JobCard) minus the AI-rank tag and resume highlighting — the
* CV is parsed server-side after submit, so there is no extracted text to
* light requirement chips against yet.
*/
function RoleCard({ post, selected, onSelect, disabled }) {
const meta = [
post?.employment_type,
post?.location,
post?.experience_min != null || post?.experience_max != null
? `${post?.experience_min ?? '?'}${post?.experience_max ?? '?'} yrs`
: null,
].filter(Boolean).join(' · ')
return (
<div
role="radio"
aria-checked={selected}
tabIndex={0}
className="list-row"
onClick={() => !disabled && onSelect(String(post.id))}
onKeyDown={(e) => {
if (disabled) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(String(post.id))
}
}}
style={{
cursor: disabled ? 'default' : 'pointer',
borderColor: selected ? 'var(--primary)' : undefined,
boxShadow: selected ? 'var(--ring)' : undefined,
marginBottom: 8,
alignItems: 'flex-start',
}}
>
<div className="lr-main" style={{ minWidth: 0 }}>
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
<div className="lr-title">{post.title}</div>
<Badge>{post.status || 'draft'}</Badge>
{selected && <Icon name="check-circle" />}
</div>
{meta && <div className="cell-sub">{meta}</div>}
{(post.requirements || []).length > 0 && (
<div className="k-tags" style={{ marginTop: 8 }}>
{(post.requirements || []).slice(0, 8).map((req) => (
<span key={req} className="tag">{req}</span>
))}
</div>
)}
</div>
</div>
)
}
/**
* Add Candidate — the only writer on this screen that reaches the server.
*
* POST /candidate/create/candidate persists the row and, for an unseen email,
* the `users` record behind it. The CV is not optional there: the route requires
* the file and refuses it when no text can be extracted, so the dropzone below
* the fields is part of the contract rather than a convenience.
*
* Applied Job lists LIVE job posts (/job/fetch), not the seed catalogue, because
* job_post_id is a job_posts FK and a seed id would be coerced to NULL without
* an error — the link would look saved and simply not exist.
*
* Manual rows do not pass through `inbox`, so /candidate/fetch may not surface
* them immediately; the save still invalidates the candidates query so the
* live-backed screens refetch and pick the row up once an application links it.
*
* On success the CV and job go back to the parent (onSave), which scores the
* file against that job via POST /candidate/score. The Matching-style role
* cards above the dropzone are that "what to score against" choice — which is
* why the picker sits with the CV rather than among the identity fields.
*/
function AddCandidate({ onClose, onSave, onInvalid }) {
const { toast } = useToast()
const qc = useQueryClient()
const fileInput = useRef(null)
const [cv, setCv] = useState(null)
const [dragging, setDragging] = useState(false)
const postsQuery = useQuery({
queryKey: qk.jobPosts.list({ top: JOB_POST_LIMIT }),
queryFn: async () => {
const res = await jobPostsApi.list({ top: JOB_POST_LIMIT })
return Array.isArray(res?.data) ? res.data : []
},
})
const posts = postsQuery.data ?? []
const form = useFormState({
name: '', email: '', phone: '', job: '',
experience: '3', company: '', position: '', source: sources[0], stage: stages[0],
referral: '',
})
const lookupEmail = (form.values.email || '').trim().toLowerCase()
const [debouncedEmail, setDebouncedEmail] = useState('')
useEffect(() => {
const timer = setTimeout(() => setDebouncedEmail(lookupEmail), 400)
return () => clearTimeout(timer)
}, [lookupEmail])
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(debouncedEmail)
const priorQuery = useQuery({
queryKey: qk.candidates.applications(debouncedEmail),
queryFn: async () => {
const res = await candidatesApi.fetchApplicationHistory(debouncedEmail)
return res?.data ?? null
},
enabled: emailLooksValid,
staleTime: 30_000,
})
const priorHistory = priorQuery.data
const priorRow = priorHistory?.found
? {
is_reapplicant: Boolean(priorHistory.is_reapplicant),
previous_applications: Array.isArray(priorHistory.applications) ? priorHistory.applications : [],
}
: null
// Defaulting by derivation rather than in an effect: the picker resolves after
// first paint, and useFormState's setters are new every render, so seeding the
// field from an effect would either loop or need a ref to guard it.
const jobPostId = form.values.job || (posts[0] ? String(posts[0].id) : '')
const [roleSearch, setRoleSearch] = useState('')
// Client-side filter: the posts are already fetched, and a PickRoleModal-style
// server search would refetch on every keystroke for the same rows.
const visiblePosts = useMemo(() => {
const needle = roleSearch.trim().toLowerCase()
if (!needle) return posts
return posts.filter((p) => (
(p.title || '').toLowerCase().includes(needle)
|| (p.location || '').toLowerCase().includes(needle)
))
}, [posts, roleSearch])
const create = useMutation({
mutationFn: (vars) => candidatesApi.createManual(vars),
onError: (err) => toast(friendlyAuthError(err, 'Could not add the candidate.'), 'error'),
onSuccess: (_res, vars) => {
// The new user_id lands in /candidate/fetch's join the moment an
// application exists for them, so let the live-backed screens refetch.
qc.invalidateQueries({ queryKey: qk.candidates.all() })
// Hand the file and job back so the parent can score the CV — from the
// mutate vars, not local state, so a mid-flight field edit cannot skew it.
onSave({ jobPostId: vars.jobPostId, file: vars.file })
},
})
function pickFile(next) {
if (!next) return
const name = (next.name || '').toLowerCase()
const mime = (next.type || '').toLowerCase()
// Gate at the picker — never hold a non-PDF in state or post it.
if (!name.endsWith('.pdf')) {
setCv(null)
form.setErrors((prev) => ({ ...prev, cv: 'Only PDF resumes are allowed' }))
toast('Only PDF files are allowed', 'error')
return
}
if (mime && mime !== 'application/pdf' && mime !== 'application/x-pdf') {
setCv(null)
form.setErrors((prev) => ({ ...prev, cv: 'Only PDF MIME types are allowed' }))
toast('Only PDF files are allowed', 'error')
return
}
setCv(next)
form.setErrors((prev) => {
if (!prev.cv) return prev
const rest = { ...prev }
delete rest.cv
return rest
})
}
function submit() {
if (create.isPending) return
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'
// Only enforceable when the picker actually has something to pick — the
// column is nullable server-side.
if (posts.length && !jobPostId) errors.job = 'Required'
if (!cv) errors.cv = 'Attach the candidates CV'
else if (!/\.pdf$/i.test(cv.name)) errors.cv = 'Only PDF resumes can be parsed'
else if (cv.size > MAX_CV_MB * 1024 * 1024) errors.cv = `Keep the file under ${MAX_CV_MB} MB`
// Optional: trimmed first, so a field holding only spaces is genuinely empty
// and passes rather than failing the pattern. Anything left must be a
// company address — the pattern rejects interior spaces on its own.
const referral = referralValue(v.referral)
if (referral && !REFERRAL_RE.test(referral)) {
errors.referral = `Must be a @${REFERRAL_DOMAIN} address`
}
form.setErrors(errors)
if (Object.keys(errors).length) {
onInvalid()
return
}
create.mutate({
file: cv,
name: v.name,
email: v.email,
phone: v.phone,
jobPostId,
company: v.company,
currentPosition: v.position,
source: v.source,
experience: v.experience,
stage: v.stage,
referralBy: referral,
})
}
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} disabled={create.isPending}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={create.isPending}>
<Icon name="check" /> {create.isPending ? 'Adding…' : 'Add Candidate'}
</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
{priorHistory?.found && priorRow?.previous_applications.length > 0 && (
<PreviousApplications row={priorRow} title="This email already applied" />
)}
{priorHistory?.found && !(priorRow?.previous_applications.length) && (
<div
className="card"
style={{
boxShadow: 'none',
background: 'var(--warning-soft)',
border: '1px solid var(--warning)',
marginBottom: 18,
}}
>
<div className="card-body text-sm">
This email already has a candidate account
{priorHistory.user?.name ? ` (${priorHistory.user.name})` : ''}.
</div>
</div>
)}
<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>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>Current Position</label><input {...field('position')} placeholder="Senior Merchandiser" /></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>
{/* Optional, and deliberately not gated on Source === 'Referral':
a referrer is worth recording whenever there is one, and referrals
routinely arrive tagged as LinkedIn or Company Site. */}
<div className="form-field">
<label>Referral By</label>
<input
type="email"
{...field('referral')}
// Normalising on blur means the value the recruiter sees is the
// value that gets posted — otherwise a pasted address with a
// trailing space would submit clean while still looking untidy.
onBlur={(e) => form.setField('referral', referralValue(e.target.value))}
className={form.errors.referral ? 'err' : ''}
placeholder={`name@${REFERRAL_DOMAIN}`}
/>
<FieldError>{form.errors.referral}</FieldError>
</div>
</div>
{/* Role selection sits directly above the CV because it is what the CV
gets scored against — same card UI as Job Matching's role list.
(.req is scoped to `.form-field label .req`, so tint it here.) */}
<h3 className="form-section-title">
Applied Job <span className="req" style={{ color: 'var(--danger)' }}>*</span>
</h3>
{posts.length > 0 && (
<div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 10 }}>
<Icon name="search" />
<input
value={roleSearch}
onChange={(e) => setRoleSearch(e.target.value)}
placeholder="Search title or location…"
/>
</div>
)}
{postsQuery.isPending && (
<EmptyState icon="briefcase" title="Loading roles…">Fetching open job posts.</EmptyState>
)}
{postsQuery.isError && (
<EmptyState icon="alert" title="Couldnt load roles">
{friendlyAuthError(postsQuery.error, 'Request failed')}
</EmptyState>
)}
{postsQuery.isSuccess && posts.length === 0 && (
<EmptyState icon="briefcase" title="No active job posts">
The candidate is saved without a role link create a job post first to score CVs.
</EmptyState>
)}
{visiblePosts.length > 0 && (
<div
role="radiogroup"
aria-label="Applied job"
style={{ maxHeight: 264, overflowY: 'auto', paddingRight: 2 }}
>
{visiblePosts.map((p) => (
<RoleCard
key={p.id}
post={p}
selected={String(p.id) === jobPostId}
onSelect={(id) => form.setField('job', id)}
disabled={create.isPending}
/>
))}
</div>
)}
{postsQuery.isSuccess && posts.length > 0 && visiblePosts.length === 0 && (
<EmptyState icon="briefcase" title="No roles found">Try a different search.</EmptyState>
)}
<FieldError>{form.errors.job}</FieldError>
<h3 className="form-section-title">
CV / Resume <span className="req" style={{ color: 'var(--danger)' }}>*</span>
</h3>
<input
ref={fileInput}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(e) => { pickFile(e.target.files?.[0]); e.target.value = '' }}
/>
<div
className={`dropzone${dragging ? ' drag' : ''}`}
style={{ padding: '22px 18px', cursor: create.isPending ? 'default' : 'pointer' }}
role="button"
tabIndex={0}
onClick={() => { if (!create.isPending) fileInput.current?.click() }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.current?.click() }
}}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
pickFile(e.dataTransfer.files?.[0])
}}
>
<div className="dz-icn" style={{ width: 44, height: 44, borderRadius: 13, marginBottom: 10 }}>
<Icon name="upload" />
</div>
<h3>Drop the CV here or click to browse</h3>
<p className="text-muted text-sm">
PDF only · text-based resumes · up to {MAX_CV_MB} MB
</p>
</div>
{cv && (
<div className="upload-row">
<span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span>
<div className="flex-1">
<div className="fw-600 text-sm">{cv.name}</div>
<div className="cell-sub">{Math.max(1, Math.round(cv.size / 1024))} KB</div>
</div>
<button
type="button"
className="act-btn"
aria-label="Remove file"
disabled={create.isPending}
onClick={() => setCv(null)}
>
<Icon name="trash" />
</button>
</div>
)}
<FieldError>{form.errors.cv}</FieldError>
</form>
</Modal>
)
}