/* ============================================================ CV Bank — people we already have, for jobs we do not have yet. Two populations, one table (GET /candidate/cv-bank/fetch): Speculative a CV uploaded with no job attached. Skills, title, company and years are extracted at upload, which is what makes the row searchable at all. Silver medalist someone who applied, scored well, and did not get the job. Read live from their application rather than copied here, so there is one source of truth and nothing to sync. 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 } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' 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' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as s3Api from '../api/s3' import { avatarColor, initials as initialsOf } from '../data/seed' const SEARCH_DEBOUNCE_MS = 300 const SOURCE_FILTERS = ['speculative', 'silver_medalist'] const SOURCE_LABELS = candidatesApi.BANK_SOURCE_LABELS const BAND_FILTERS = ['Strong Match', 'Potential Match', 'Weak Match', 'Unscored'] const YEARS_FILTERS = ['1', '2', '3', '5', '8', '10'] const BAND_BADGE = { 'Strong Match': 'b-green', 'Potential Match': 'b-amber', 'Weak Match': 'b-gray', } const SOURCE_BADGE = { speculative: 'b-indigo', silver_medalist: 'b-teal', } /* 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 }) { const res = await candidatesApi.listCvBank({ top: limit, skip: offset, search: search || undefined, source: filters.source || undefined, band: filters.band || undefined, minYears: filters.years ? Number(filters.years) : undefined, }) const rows = Array.isArray(res?.data) ? res.data : [] return { rows: rows.map(candidatesApi.toBankRowView), total: Number(res?.total ?? rows.length) || 0, } } function AtsCell({ score, recommendation }) { if (score == null) return Not scored return (
{recommendation && (
{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 [q, setQ] = useState('') const [search, setSearch] = useState('') const [filters, setFilters] = useState(EMPTY_FILTERS) const [showFilters, setShowFilters] = useState(false) const [skip, setSkip] = useState(0) const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE) const [preview, setPreview] = useState(null) // { name, url } — object URL we own const [pickingRow, setPickingRow] = useState(null) const [pickedById, setPickedById] = useState({}) useEffect(() => { const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS) return () => clearTimeout(t) }, [q]) useEffect(() => { setSkip(0) }, [search]) const bankQuery = useQuery({ queryKey: qk.cvBank.list({ limit: pageSize, offset: skip, search, ...filters }), queryFn: () => fetchBank({ limit: pageSize, offset: skip, search, filters }), }) const rows = useMemo(() => bankQuery.data?.rows ?? [], [bankQuery.data]) const total = bankQuery.data?.total ?? 0 const pages = Math.max(1, Math.ceil(total / pageSize)) const from = total ? skip + 1 : 0 const to = total ? skip + rows.length : 0 const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages) useEffect(() => { if (total <= 0 || skip < total) return setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize)) }, [total, pageSize, skip]) const columns = useMemo(() => [ { key: 'name', label: 'Candidate', sortable: true }, { key: 'source', label: 'Source', sortable: true }, { key: 'title', label: 'Role', sortable: true }, { key: 'years', label: 'Years', sortable: true }, { key: 'skills', label: 'Skills', sortable: false }, { 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 }, ], []) // The server already sorted and paged; pageSize here is just "show them all". const t = useDataTable({ columns, rows, pageSize: Math.max(rows.length, 1) }) const setFilter = (k, v) => { setFilters((f) => ({ ...f, [k]: v })) setSkip(0) } const removing = useMutation({ mutationFn: (id) => candidatesApi.deleteCvBankCv(id), onSuccess: () => { qc.invalidateQueries({ queryKey: qk.cvBank.all() }) toast('CV removed from the bank', 'success') }, onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'), }) 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() }) qc.invalidateQueries({ queryKey: qk.pipeline.all() }) }, onError: (err) => toast(friendlyAuthError(err, 'Scoring failed'), 'error'), }) async function view(row) { if (!row.isStoredCv) { if (row.userId) navigate(`/candidate/${row.userId}`) else toast('This applicant has no profile to open', 'info') return } const tab = s3Api.canOpen(row.filePath) ? window.open('about:blank', '_blank') : null try { if (s3Api.canOpen(row.filePath)) { await s3Api.openPdf(row.filePath, { tab }) return } const url = await candidatesApi.viewCvBankCv(row.recordId) if (!url) { toast('The CV file could not be found', 'error') return } setPreview({ name: row.fileName || row.name || 'CV', url }) } catch (err) { if (tab && !tab.closed) tab.close() toast(friendlyAuthError(err, 'Could not open the CV'), 'error') } } function closePreview() { if (preview) URL.revokeObjectURL(preview.url) setPreview(null) } async function download(row) { try { await candidatesApi.downloadCvBankCv(row.recordId) } catch (err) { toast(friendlyAuthError(err, 'Could not download the CV'), 'error') } } async function exportRows() { if (!rows.length) { toast('Nothing to export — current filters match no CVs', 'warning') return } try { await exportStyledXlsx({ filename: `cv-bank-${new Date().toISOString().slice(0, 10)}`, title: 'CV Bank', 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 }, { header: 'Source', key: 'source', width: 16 }, { header: 'Title', key: 'title', width: 24 }, { header: 'Company', key: 'company', width: 24 }, { header: 'Years', key: 'years', width: 8 }, { header: 'Skills', key: 'skills', width: 42 }, { 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 }, ], rows: rows.map((r) => ({ name: r.name, email: r.email || '', source: r.sourceLabel, title: r.title || '', company: r.company || '', years: r.years ?? '', skills: r.skills.join(', '), suggested: r.suggestedJobs.map((j) => j.title).filter(Boolean).join(', '), ats: r.aiScore ?? '', scoredJob: jobFor(r, pickedById)?.title || '', added: r.added ? r.added.toLocaleDateString() : '', })), }) toast(`Exported ${rows.length} CV${rows.length === 1 ? '' : 's'}`, 'success') } catch { toast('Export failed', 'error') } } return (
{total} CV{total === 1 ? '' : 's'} held for future roles : 'CVs held for future roles' } actions={<> } />
setQ(e.target.value)} placeholder="Search name, email, company, or skill…" />
{showFilters && (
setFilter('source', v)} any="Any source" options={SOURCE_FILTERS} labels={SOURCE_LABELS} /> setFilter('band', v)} any="Any band" options={BAND_FILTERS} /> setFilter('years', v)} any="Any experience" options={YEARS_FILTERS} labels={Object.fromEntries(YEARS_FILTERS.map((y) => [y, `${y}+ years`]))} />
)}
{bankQuery.isPending && (
)} {bankQuery.isError && (
{friendlyAuthError(bankQuery.error, 'Request failed')}
)} {bankQuery.isSuccess && (
{t.pageRows.length === 0 ? ( ) : ( 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 ( ) }) )}
{search || filters.source || filters.band || filters.years ? ( No held CV matches these filters. Try widening them. ) : ( Import CVs with “No job — store in CV bank” selected, and rejected applicants who scored well will show up here too. )}
{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.assignedJobPostId && ( )} )}
setSkip((p - 1) * pageSize)} pageButtons={pageWindow(currentPage, pages)} pageSize={pageSize} onPageSizeChange={(n) => setPageSize(n)} pageSizeMax={500} />
)}

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.

{pickingRow && ( setPickingRow(null)} onPick={(post) => { if (!post?.id) return setPickedById((m) => ({ ...m, [pickingRow.id]: { id: String(post.id), title: post.title || 'Selected job' }, })) }} /> )} {preview && ( Close} > {/* Blob URL re-typed to application/pdf so the browser's built-in viewer renders inline instead of triggering a download. */}