commited
parent
9ffa2b1367
commit
493120d906
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue