1018 lines
42 KiB
JavaScript
1018 lines
42 KiB
JavaScript
/* ============================================================
|
||
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 A–Z</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="Couldn’t 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 candidate’s 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="Couldn’t 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>
|
||
)
|
||
}
|