pull/88/head
ahmed.mujtaba 2026-09-10 15:15:24 +05:00
parent 9ffa2b1367
commit 493120d906
1 changed files with 192 additions and 246 deletions

View File

@ -10,19 +10,14 @@
job. Read live from their application rather than copied
here, so there is one source of truth and nothing to sync.
Two very different numbers live on this screen and must not be confused:
Match free, deterministic keyword overlap against the job picked in
"Rank against job". It orders the pile. It is not an assessment.
ATS a real paid score, and only present once someone ran one. The
"Score against job" action is what runs it, deliberately per-row.
That split is the whole design: ranking the bank costs nothing and happens
automatically when a job opens, so scoring can stay explicit and cheap.
ATS is a real paid score. Each row picks a job, then Run ATS scores that
one candidate. Speculative rows are assigned to the job first (so the banked
CV links like any other application), then scored. Silver medalists stay on
their inbox application and score via score_inbox when a message id exists.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
@ -30,6 +25,7 @@ import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader'
import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
import { PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx'
@ -55,10 +51,11 @@ const SOURCE_BADGE = {
/* Chips past this are collapsed into "+N" a CV with 25 skills would
otherwise make one row taller than the rest of the page. */
const SKILL_CHIPS = 4
const SUGGESTED_CHIPS = 3
const EMPTY_FILTERS = { source: '', band: '', years: '' }
async function fetchBank({ limit, offset, search, filters, jobPostId }) {
async function fetchBank({ limit, offset, search, filters }) {
const res = await candidatesApi.listCvBank({
top: limit,
skip: offset,
@ -66,7 +63,6 @@ async function fetchBank({ limit, offset, search, filters, jobPostId }) {
source: filters.source || undefined,
band: filters.band || undefined,
minYears: filters.years ? Number(filters.years) : undefined,
jobPostId: jobPostId || undefined,
})
const rows = Array.isArray(res?.data) ? res.data : []
return {
@ -75,30 +71,6 @@ async function fetchBank({ limit, offset, search, filters, jobPostId }) {
}
}
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: String(row.id), title: row.title }))
}
/** The free deterministic rank. Drawn as a plain bar, never as a ScoreChip
a recruiter must not read it in the same visual language as a real ATS score. */
function MatchCell({ rank, hasJob }) {
if (!hasJob) return <span className="text-muted text-sm">Pick a job</span>
if (rank == null) return <span className="text-muted"></span>
return (
<div style={{ minWidth: 92 }}>
<div className="fw-600 text-sm">{rank}<span className="text-muted" style={{ fontWeight: 400 }}>/100</span></div>
<div
style={{ height: 4, borderRadius: 2, background: 'var(--bg-sunken)', marginTop: 4 }}
aria-hidden="true"
>
<div style={{ width: `${Math.min(100, rank)}%`, height: '100%', borderRadius: 2, background: 'var(--primary)' }} />
</div>
</div>
)
}
function AtsCell({ score, recommendation }) {
if (score == null) return <span className="text-muted text-sm">Not scored</span>
return (
@ -113,11 +85,43 @@ function AtsCell({ score, recommendation }) {
)
}
function SuggestedJobsCell({ jobs }) {
if (!jobs?.length) return null
return (
<div className="k-tags">
{jobs.slice(0, SUGGESTED_CHIPS).map((j) => (
<span className="tag" key={j.id} title={j.title}>{j.title || 'Job'}</span>
))}
{jobs.length > SUGGESTED_CHIPS && (
<span className="tag" title={jobs.slice(SUGGESTED_CHIPS).map((j) => j.title).join(', ')}>
+{jobs.length - SUGGESTED_CHIPS}
</span>
)}
</div>
)
}
function jobFor(row, pickedById) {
const local = pickedById[row.id]
if (local?.id) return local
if (row.scoredJobPostId) {
return { id: row.scoredJobPostId, title: row.scoredJobTitle || 'Selected job' }
}
if (row.assignedJobPostId) {
return { id: row.assignedJobPostId, title: row.assignedJobTitle || 'Assigned job' }
}
return null
}
function scoredRow(res) {
const row = Array.isArray(res?.data) ? res.data[0] : res?.data
return row && typeof row === 'object' ? row : null
}
export default function CvBank() {
const { toast } = useToast()
const qc = useQueryClient()
const navigate = useNavigate()
const [params, setParams] = useSearchParams()
const [q, setQ] = useState('')
const [search, setSearch] = useState('')
@ -126,19 +130,8 @@ export default function CvBank() {
const [skip, setSkip] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [preview, setPreview] = useState(null) // { name, url } object URL we own
const [scoreFor, setScoreFor] = useState(null)
const [assignFor, setAssignFor] = useState(null)
/* The rank job lives in the URL so the notification fired on job creation
("/cvbank?job=<id>") lands on the ranked view rather than a generic list. */
const jobPostId = params.get('job') || ''
const setJobPostId = (next) => {
const p = new URLSearchParams(params)
if (next) p.set('job', next)
else p.delete('job')
setParams(p, { replace: true })
setSkip(0)
}
const [pickingRow, setPickingRow] = useState(null)
const [pickedById, setPickedById] = useState({})
useEffect(() => {
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
@ -147,15 +140,12 @@ export default function CvBank() {
useEffect(() => { setSkip(0) }, [search])
const bankQuery = useQuery({
queryKey: qk.cvBank.list({ limit: pageSize, offset: skip, search, ...filters, jobPostId }),
queryFn: () => fetchBank({ limit: pageSize, offset: skip, search, filters, jobPostId }),
queryKey: qk.cvBank.list({ limit: pageSize, offset: skip, search, ...filters }),
queryFn: () => fetchBank({ limit: pageSize, offset: skip, search, filters }),
})
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const rows = useMemo(() => bankQuery.data?.rows ?? [], [bankQuery.data])
const total = bankQuery.data?.total ?? 0
const jobs = jobsQuery.data ?? []
const selectedJob = jobs.find((j) => j.id === jobPostId) || null
const pages = Math.max(1, Math.ceil(total / pageSize))
const from = total ? skip + 1 : 0
@ -173,8 +163,9 @@ export default function CvBank() {
{ key: 'title', label: 'Role', sortable: true },
{ key: 'years', label: 'Years', sortable: true },
{ key: 'skills', label: 'Skills', sortable: false },
{ key: 'rankScore', label: 'Match', sortable: true },
{ key: 'suggestedJobs', label: 'Suggested jobs', sortable: false },
{ key: 'aiScore', label: 'ATS', sortable: true },
{ key: 'job', label: 'Job', sortable: false },
{ key: 'added', label: 'Added', sortable: true },
{ key: 'actions', label: '', sortable: false },
], [])
@ -196,33 +187,32 @@ export default function CvBank() {
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
})
const scoring = useMutation({
mutationFn: ({ jobId, ids }) => candidatesApi.scoreCvBank(jobId, ids),
onSuccess: (res) => {
const row = Array.isArray(res?.data) ? res.data[0] : null
if (row?.status === 'completed') {
toast(`Scored ${row.match_score}/100 — the result is on Candidates now`, 'success')
const runAts = useMutation({
mutationFn: async ({ row, jobId }) => {
if (row.isStoredCv) {
await candidatesApi.assignMatchingJob(row.recordId, jobId)
return candidatesApi.scoreCvBank(jobId, [row.recordId])
}
if (row.messageId) {
return candidatesApi.scoreInbox(jobId, [row.messageId])
}
throw new Error('This applicant cannot be scored from the CV Bank')
},
onSuccess: (res, vars) => {
const row = scoredRow(res)
const title = vars.jobTitle || 'the selected job'
if (row?.status === 'completed' || (row?.match_score != null && row?.status !== 'failed')) {
toast(`Scored ${row.match_score}/100 against ${title}`, 'success')
} else {
toast(`Could not score the CV${row?.error_code ? `${row.error_code}` : ''}`, 'warning')
}
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
qc.invalidateQueries({ queryKey: qk.candidates.all() })
setScoreFor(null)
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
},
onError: (err) => toast(friendlyAuthError(err, 'Scoring failed'), 'error'),
})
const assigning = useMutation({
mutationFn: ({ id, jobId }) => candidatesApi.assignMatchingJob(id, jobId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
qc.invalidateQueries({ queryKey: qk.candidates.all() })
toast('CV assigned — it is in the pipeline now', 'success')
setAssignFor(null)
},
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the job'), 'error'),
})
async function view(row) {
if (!row.isStoredCv) {
if (row.userId) navigate(`/candidate/${row.userId}`)
@ -269,7 +259,7 @@ export default function CvBank() {
await exportStyledXlsx({
filename: `cv-bank-${new Date().toISOString().slice(0, 10)}`,
title: 'CV Bank',
subtitle: `${rows.length} CV${rows.length === 1 ? '' : 's'}${selectedJob ? ` · ranked against ${selectedJob.title}` : ''} · exported ${new Date().toLocaleDateString()}`,
subtitle: `${rows.length} CV${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
columns: [
{ header: 'Name', key: 'name', width: 26 },
{ header: 'Email', key: 'email', width: 30 },
@ -278,12 +268,11 @@ export default function CvBank() {
{ header: 'Company', key: 'company', width: 24 },
{ header: 'Years', key: 'years', width: 8 },
{ header: 'Skills', key: 'skills', width: 42 },
{ header: 'Match', key: 'match', width: 10 },
{ header: 'Suggested jobs', key: 'suggested', width: 32 },
{ header: 'ATS', key: 'ats', width: 10 },
{ header: 'Scored job', key: 'scoredJob', width: 24 },
{ header: 'Added', key: 'added', width: 12 },
],
// Every column is extracted from the CV or read off a real application.
// Talent Pool exported invented skills and companies; this does not.
rows: rows.map((r) => ({
name: r.name,
email: r.email || '',
@ -292,8 +281,9 @@ export default function CvBank() {
company: r.company || '',
years: r.years ?? '',
skills: r.skills.join(', '),
match: r.rankScore ?? '',
suggested: r.suggestedJobs.map((j) => j.title).filter(Boolean).join(', '),
ats: r.aiScore ?? '',
scoredJob: jobFor(r, pickedById)?.title || '',
added: r.added ? r.added.toLocaleDateString() : '',
})),
})
@ -309,7 +299,7 @@ export default function CvBank() {
title="CV Bank"
sub={
bankQuery.isSuccess
? <>{total} CV{total === 1 ? '' : 's'} held for future roles{selectedJob ? <> · ranked against <strong>{selectedJob.title}</strong></> : null}</>
? <>{total} CV{total === 1 ? '' : 's'} held for future roles</>
: 'CVs held for future roles'
}
actions={<>
@ -337,20 +327,6 @@ export default function CvBank() {
<Icon name="filter" /> Filters
</button>
<div className="spacer" />
{/* The "a role just opened, who do we already have" control. This is
the moment the bank is meant to be used. */}
<div className="flex items-center gap-8">
<label className="text-muted text-sm">Rank against job:</label>
<select
className="select"
value={jobPostId}
onChange={(e) => setJobPostId(e.target.value)}
disabled={jobsQuery.isPending}
>
<option value="">No job selected</option>
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select>
</div>
</div>
{showFilters && (
@ -418,100 +394,124 @@ export default function CvBank() {
</td>
</tr>
) : (
t.pageRows.map((r) => (
<tr key={r.id}>
<td>
<div className="user-cell">
<Avatar name={r.name} initials={initialsOf(r.name)} color={avatarColor(r.name)} />
<div style={{ minWidth: 0 }}>
<div className="cell-primary">{r.name}</div>
<div className="cell-sub">{r.email || r.fileName || 'No email detected'}</div>
t.pageRows.map((r) => {
const picked = jobFor(r, pickedById)
const scoringThis = runAts.isPending && runAts.variables?.row?.id === r.id
const runDisabled = !r.canRunAts || !picked || runAts.isPending
const runTitle = !r.canRunAts
? 'Silver medalists are scored from their application — this row has no inbox CV to score'
: !picked
? 'Pick a job first'
: 'Run the ATS score for this candidate'
return (
<tr key={r.id}>
<td>
<div className="user-cell">
<Avatar name={r.name} initials={initialsOf(r.name)} color={avatarColor(r.name)} />
<div style={{ minWidth: 0 }}>
<div className="cell-primary">{r.name}</div>
<div className="cell-sub">{r.email || r.fileName || 'No email detected'}</div>
</div>
</div>
</div>
</td>
<td>
<Badge className={SOURCE_BADGE[r.source] || 'b-gray'}>{r.sourceLabel}</Badge>
{r.lastJobTitle && (
<div className="cell-sub cell-clip" title={r.lastJobTitle}>
applied for {r.lastJobTitle}
</div>
)}
{r.expiresAt && (
<div className="cell-sub">{candidatesApi.expiryLabel(r.expiresAt)}</div>
)}
</td>
<td>
<div className="text-sm cell-clip" title={r.title || undefined}>{r.title || '—'}</div>
{r.company && <div className="cell-sub cell-clip" title={r.company}>{r.company}</div>}
</td>
<td>
<span className="text-sm">{r.years == null ? '—' : r.years}</span>
</td>
<td>
{r.skills.length ? (
<div className="k-tags">
{r.skills.slice(0, SKILL_CHIPS).map((s) => (
<span className="tag" key={s}>{s}</span>
))}
{r.skills.length > SKILL_CHIPS && (
<span className="tag" title={r.skills.slice(SKILL_CHIPS).join(', ')}>
+{r.skills.length - SKILL_CHIPS}
</span>
)}
</div>
) : (
<span className="text-muted text-sm">None extracted</span>
)}
</td>
<td><MatchCell rank={r.rankScore} hasJob={Boolean(jobPostId)} /></td>
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
<td>
<span className="text-sm">{r.added ? r.added.toLocaleDateString() : '—'}</span>
</td>
<td>
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
{r.isStoredCv && <OpenResumeButton filePath={r.filePath} className="btn btn-secondary btn-sm" />}
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
<Icon name="eye" />
</button>
{r.isStoredCv && (<>
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
<Icon name="download" />
</button>
</td>
<td>
<Badge className={SOURCE_BADGE[r.source] || 'b-gray'}>{r.sourceLabel}</Badge>
{r.lastJobTitle && (
<div className="cell-sub cell-clip" title={r.lastJobTitle}>
applied for {r.lastJobTitle}
</div>
)}
{r.expiresAt && (
<div className="cell-sub">{candidatesApi.expiryLabel(r.expiresAt)}</div>
)}
</td>
<td>
<div className="text-sm cell-clip" title={r.title || undefined}>{r.title || '—'}</div>
{r.company && <div className="cell-sub cell-clip" title={r.company}>{r.company}</div>}
</td>
<td>
<span className="text-sm">{r.years == null ? '—' : r.years}</span>
</td>
<td>
{r.skills.length ? (
<div className="k-tags">
{r.skills.slice(0, SKILL_CHIPS).map((s) => (
<span className="tag" key={s}>{s}</span>
))}
{r.skills.length > SKILL_CHIPS && (
<span className="tag" title={r.skills.slice(SKILL_CHIPS).join(', ')}>
+{r.skills.length - SKILL_CHIPS}
</span>
)}
</div>
) : (
<span className="text-muted text-sm">None extracted</span>
)}
</td>
<td><SuggestedJobsCell jobs={r.suggestedJobs} /></td>
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
<td>
<div className="flex items-center gap-8" style={{ flexWrap: 'wrap' }}>
<button
className="act-btn"
data-tip="Score against a job"
aria-label="Score this CV against a job"
onClick={() => setScoreFor(r)}
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setPickingRow(r)}
title={picked ? `ATS job: ${picked.title}` : 'Pick a job to score against'}
>
<Icon name="target" />
{picked?.title || 'Pick a job'}
</button>
<button
className="act-btn"
data-tip="Assign to a job"
aria-label="Assign this CV to a job"
onClick={() => setAssignFor(r)}
>
<Icon name="briefcase" />
</button>
<button
className="act-btn"
data-tip="Remove"
aria-label="Remove CV from bank"
disabled={removing.isPending}
type="button"
className="btn btn-primary btn-sm"
disabled={runDisabled}
title={runTitle}
onClick={() => {
if (window.confirm(`Remove “${r.fileName || r.name}” from the CV Bank? The file is deleted permanently.`)) {
removing.mutate(r.recordId)
}
if (!picked || runDisabled) return
runAts.mutate({
row: r,
jobId: picked.id,
jobTitle: picked.title,
})
}}
>
<Icon name="trash" />
{scoringThis ? 'Scoring…' : 'Run ATS'}
</button>
</>)}
</div>
</td>
</tr>
))
</div>
</td>
<td>
<span className="text-sm">{r.added ? r.added.toLocaleDateString() : '—'}</span>
</td>
<td>
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
{r.isStoredCv && <OpenResumeButton filePath={r.filePath} className="btn btn-secondary btn-sm" />}
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
<Icon name="eye" />
</button>
{r.isStoredCv && (<>
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
<Icon name="download" />
</button>
{!r.assignedJobPostId && (
<button
className="act-btn"
data-tip="Remove"
aria-label="Remove CV from bank"
disabled={removing.isPending}
onClick={() => {
if (window.confirm(`Remove “${r.fileName || r.name}” from the CV Bank? The file is deleted permanently.`)) {
removing.mutate(r.recordId)
}
}}
>
<Icon name="trash" />
</button>
)}
</>)}
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
@ -533,36 +533,21 @@ export default function CvBank() {
</div>
<p className="text-muted text-sm mt-18">
<Icon name="info" /> <strong>Match</strong> is free keyword overlap against the selected
job it orders this list, it does not assess anyone. <strong>ATS</strong> is a real
scored result and only appears once someone runs one.
<Icon name="info" /> Pick a job on a row, then <strong>Run ATS</strong> for a
real scored result. Speculative CVs are linked to that job; silver medalists
stay on their existing application.
</p>
{scoreFor && (
<JobPickerModal
title="Score against a job"
subtitle={`Run the real ATS score for ${scoreFor.name}`}
note="This calls the scoring model and costs money. The result lands in Candidates like any other scored CV."
confirmLabel="Score CV"
jobs={jobs}
defaultJobId={jobPostId}
pending={scoring.isPending}
onClose={() => setScoreFor(null)}
onConfirm={(jobId) => scoring.mutate({ jobId, ids: [scoreFor.recordId] })}
/>
)}
{assignFor && (
<JobPickerModal
title="Assign to a job"
subtitle={`Move ${assignFor.name} onto a job post`}
note="The CV leaves the bank and enters the pipeline as an application. It is not scored by this action."
confirmLabel="Assign"
jobs={jobs}
defaultJobId={jobPostId}
pending={assigning.isPending}
onClose={() => setAssignFor(null)}
onConfirm={(jobId) => assigning.mutate({ id: assignFor.recordId, jobId })}
{pickingRow && (
<PickRoleModal
onClose={() => setPickingRow(null)}
onPick={(post) => {
if (!post?.id) return
setPickedById((m) => ({
...m,
[pickingRow.id]: { id: String(post.id), title: post.title || 'Selected job' },
}))
}}
/>
)}
@ -598,42 +583,3 @@ function Facet({ label, value, onChange, any, options, labels }) {
</div>
)
}
/** Shared by the score and assign actions — both need exactly one job post. */
function JobPickerModal({ title, subtitle, note, confirmLabel, jobs, defaultJobId, pending, onClose, onConfirm }) {
const [jobId, setJobId] = useState(defaultJobId || (jobs[0]?.id ?? ''))
return (
<Modal
title={title}
subtitle={subtitle}
onClose={onClose}
footer={<>
<button className="btn btn-secondary" onClick={onClose} disabled={pending}>Cancel</button>
<button
className="btn btn-primary"
disabled={pending || !jobId}
onClick={() => onConfirm(jobId)}
>
<Icon name="check" /> {pending ? 'Working…' : confirmLabel}
</button>
</>}
>
{jobs.length === 0 ? (
<EmptyState icon="briefcase" title="No job posts yet">
Create a job post first there is nothing to match against.
</EmptyState>
) : (
<>
<div className="form-field">
<label>Job post</label>
<select value={jobId} onChange={(e) => setJobId(e.target.value)}>
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select>
</div>
<p className="text-muted text-sm">{note}</p>
</>
)}
</Modal>
)
}