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

1018 lines
42 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 — the scored-candidate pool, 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.
============================================================ */
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 { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow, useDataTable } from '../ui/DataTable'
import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline'
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: '' }
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', '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 fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
}
function recommendationOf(c) {
if (c.aiScore == null) return 'Weak Match'
return c.aiScore >= 82 ? 'Strong Match' : c.aiScore >= 65 ? 'Potential Match' : 'Weak Match'
}
/* 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()
export default function Candidates() {
const { toast } = useToast()
const qc = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const updateCandidates = useSeedMutation('candidates')
const [q, setQ] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('recent')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
const skip = (page - 1) * pageSize
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,
})
const candidatesQuery = useQuery({
queryKey: qk.candidates.list({ top: pageSize, skip }),
queryFn: () => fetchCandidates({ top: pageSize, skip }),
})
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data])
const jobsById = useMemo(
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
[jobsQuery.data],
)
const { data: recentlyViewed = [] } = useQuery({
queryKey: qk.seed.recentlyViewed(),
queryFn: async () => [],
staleTime: Infinity,
gcTime: Infinity,
})
const jobTitleOf = useCallback(
(c) => 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
/* 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 = []) => {
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 || c.id
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 (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
}
return true
})
if (sortMode === 'recent') list = [...list].sort((a, b) => (b.applied ?? 0) - (a.applied ?? 0))
else if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
return list
}, [candidates, filters, q, sortMode])
/* 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: 'isActive', label: 'Account', 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 pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pages)
useEffect(() => {
if (page > pages) setPage(pages)
}, [page, pages])
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 }))
/* 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')
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} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}</>}
actions={<>
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}>
<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, skill, company…" />
</div>
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
<Icon name="filter" /> Filters
</button>
<div className="spacer" />
<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="name">Name AZ</option>
</select>
</div>
{showFilters && (
<div
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']} />
</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="No candidates yet">
Score resumes in CV Import to fill this table.
</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>
)}
</div>
<div className="cell-sub">{c.roleName ?? '—'}</div>
</div>
</div>
</td>
<td>
<span className="text-sm cell-clip" title={c.email || undefined}>{c.email ?? '—'}</span>
</td>
<td>
{c.isActive
? <Badge className="b-green">Active</Badge>
: <Badge className="b-amber">Unconfirmed</Badge>}
</td>
<td>
<span className="text-sm">
{c.applied ? c.applied.toLocaleDateString() : '—'}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
from={total ? (currentPage - 1) * pageSize + 1 : 0}
to={total ? (currentPage - 1) * pageSize + rows.length : 0}
total={total}
page={currentPage}
pages={pages}
setPage={setPage}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => { setPageSize(n); setPage((p) => pageAfterSizeChange(p, total, n)) }}
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) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : fmtDate(d)
}
/**
* 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
* (Talent Pool cards, the scored leaderboard).
*
* Exported so TalentPool's profile modal can open the same ATS breakdown.
*/
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: '',
})
// 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() }}>
<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>
)
}