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

586 lines
23 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.

/* ============================================================
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 <span className="text-muted text-sm">Not scored</span>
return (
<div>
<ScoreChip score={score} />
{recommendation && (
<div className="cell-sub">
<Badge className={BAND_BADGE[recommendation] || 'b-gray'}>{recommendation}</Badge>
</div>
)}
</div>
)
}
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 [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 (
<div className="page">
<PageHeader
title="CV Bank"
sub={
bankQuery.isSuccess
? <>{total} CV{total === 1 ? '' : 's'} held for future roles</>
: 'CVs held for future roles'
}
actions={<>
<button className="btn btn-secondary" onClick={exportRows}>
<Icon name="download" /> Export
</button>
<button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Add CVs
</button>
</>}
/>
<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, email, company, or skill…"
/>
</div>
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
<Icon name="filter" /> Filters
</button>
<div className="spacer" />
</div>
{showFilters && (
<div
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
<Facet
label="Source"
value={filters.source}
onChange={(v) => setFilter('source', v)}
any="Any source"
options={SOURCE_FILTERS}
labels={SOURCE_LABELS}
/>
<Facet
label="ATS band"
value={filters.band}
onChange={(v) => setFilter('band', v)}
any="Any band"
options={BAND_FILTERS}
/>
<Facet
label="Minimum years"
value={filters.years}
onChange={(v) => setFilter('years', v)}
any="Any experience"
options={YEARS_FILTERS}
labels={Object.fromEntries(YEARS_FILTERS.map((y) => [y, `${y}+ years`]))}
/>
</div>
)}
</div>
{bankQuery.isPending && (
<div className="card-body"><SkeletonRows rows={6} /></div>
)}
{bankQuery.isError && (
<div className="card-body">
<EmptyState icon="file" title="Couldnt load the CV Bank">
{friendlyAuthError(bankQuery.error, 'Request failed')}
</EmptyState>
</div>
)}
{bankQuery.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}>
{search || filters.source || filters.band || filters.years ? (
<EmptyState title="No matches">
No held CV matches these filters. Try widening them.
</EmptyState>
) : (
<EmptyState icon="file" title="The CV Bank is empty">
Import CVs with No job store in CV bank selected, and
rejected applicants who scored well will show up here too.
</EmptyState>
)}
</td>
</tr>
) : (
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>
</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
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setPickingRow(r)}
title={picked ? `ATS job: ${picked.title}` : 'Pick a job to score against'}
>
{picked?.title || 'Pick a job'}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={runDisabled}
title={runTitle}
onClick={() => {
if (!picked || runDisabled) return
runAts.mutate({
row: r,
jobId: picked.id,
jobTitle: picked.title,
})
}}
>
{scoringThis ? 'Scoring…' : 'Run ATS'}
</button>
</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>
</div>
<Pagination
from={from}
to={to}
total={total}
page={currentPage}
pages={pages}
setPage={(p) => setSkip((p - 1) * pageSize)}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => setPageSize(n)}
pageSizeMax={500}
/>
</div>
)}
</div>
<p className="text-muted text-sm mt-18">
<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>
{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' },
}))
}}
/>
)}
{preview && (
<Modal
title={preview.name}
subtitle="CV preview"
size="modal-lg"
onClose={closePreview}
footer={<button className="btn btn-secondary" onClick={closePreview}>Close</button>}
>
{/* Blob URL re-typed to application/pdf so the browser's built-in
viewer renders inline instead of triggering a download. */}
<iframe
src={preview.url}
title={`Preview of ${preview.name}`}
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 10, background: 'var(--bg-sunken)' }}
/>
</Modal>
)}
</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>
)
}