diff --git a/frontend/src/screens/CvBank.jsx b/frontend/src/screens/CvBank.jsx index 70cf1bf..41bb596 100644 --- a/frontend/src/screens/CvBank.jsx +++ b/frontend/src/screens/CvBank.jsx @@ -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 Pick a job - if (rank == null) return - return ( -
-
{rank}/100
- - ) -} - function AtsCell({ score, recommendation }) { if (score == null) return Not scored return ( @@ -113,11 +85,43 @@ function AtsCell({ score, recommendation }) { ) } +function SuggestedJobsCell({ jobs }) { + if (!jobs?.length) return null + return ( +
+ {jobs.slice(0, SUGGESTED_CHIPS).map((j) => ( + {j.title || 'Job'} + ))} + {jobs.length > SUGGESTED_CHIPS && ( + j.title).join(', ')}> + +{jobs.length - SUGGESTED_CHIPS} + + )} +
+ ) +} + +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=") 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 {selectedJob.title} : null} + ? <>{total} CV{total === 1 ? '' : 's'} held for future roles : 'CVs held for future roles' } actions={<> @@ -337,20 +327,6 @@ export default function CvBank() { Filters
- {/* The "a role just opened, who do we already have" control. This is - the moment the bank is meant to be used. */} -
- - -
{showFilters && ( @@ -418,100 +394,124 @@ export default function CvBank() { ) : ( - t.pageRows.map((r) => ( - - -
- -
-
{r.name}
-
{r.email || r.fileName || 'No email detected'}
+ 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 ( + + +
+ +
+
{r.name}
+
{r.email || r.fileName || 'No email detected'}
+
-
- - - {r.sourceLabel} - {r.lastJobTitle && ( -
- applied for {r.lastJobTitle} -
- )} - {r.expiresAt && ( -
{candidatesApi.expiryLabel(r.expiresAt)}
- )} - - -
{r.title || '—'}
- {r.company &&
{r.company}
} - - - {r.years == null ? '—' : r.years} - - - {r.skills.length ? ( -
- {r.skills.slice(0, SKILL_CHIPS).map((s) => ( - {s} - ))} - {r.skills.length > SKILL_CHIPS && ( - - +{r.skills.length - SKILL_CHIPS} - - )} -
- ) : ( - None extracted - )} - - - - - {r.added ? r.added.toLocaleDateString() : '—'} - - -
- {r.isStoredCv && } - - {r.isStoredCv && (<> - + + + {r.sourceLabel} + {r.lastJobTitle && ( +
+ applied for {r.lastJobTitle} +
+ )} + {r.expiresAt && ( +
{candidatesApi.expiryLabel(r.expiresAt)}
+ )} + + +
{r.title || '—'}
+ {r.company &&
{r.company}
} + + + {r.years == null ? '—' : r.years} + + + {r.skills.length ? ( +
+ {r.skills.slice(0, SKILL_CHIPS).map((s) => ( + {s} + ))} + {r.skills.length > SKILL_CHIPS && ( + + +{r.skills.length - SKILL_CHIPS} + + )} +
+ ) : ( + None extracted + )} + + + + +
- - )} -
- - - )) +
+ + + {r.added ? r.added.toLocaleDateString() : '—'} + + +
+ {r.isStoredCv && } + + {r.isStoredCv && (<> + + {!r.assignedJobPostId && ( + + )} + )} +
+ + + ) + }) )} @@ -533,36 +533,21 @@ export default function CvBank() {

- Match is free keyword overlap against the selected - job — it orders this list, it does not assess anyone. ATS is a real - scored result and only appears once someone runs one. + Pick a job on a row, then Run ATS for a + real scored result. Speculative CVs are linked to that job; silver medalists + stay on their existing application.

- {scoreFor && ( - setScoreFor(null)} - onConfirm={(jobId) => scoring.mutate({ jobId, ids: [scoreFor.recordId] })} - /> - )} - - {assignFor && ( - setAssignFor(null)} - onConfirm={(jobId) => assigning.mutate({ id: assignFor.recordId, jobId })} + {pickingRow && ( + 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 }) {
) } - -/** 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 ( - - - - } - > - {jobs.length === 0 ? ( - - Create a job post first — there is nothing to match against. - - ) : ( - <> -
- - -
-

{note}

- - )} -
- ) -}