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 job. Read live from their application rather than copied
here, so there is one source of truth and nothing to sync. 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: 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
Match free, deterministic keyword overlap against the job picked in CV links like any other application), then scored. Silver medalists stay on
"Rank against job". It orders the pile. It is not an assessment. their inbox application and score via score_inbox when a message id exists.
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.
============================================================ */ ============================================================ */
import { useEffect, useMemo, useState } from 'react' 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
@ -30,6 +25,7 @@ import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable' import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
import { PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx' 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 /* Chips past this are collapsed into "+N" a CV with 25 skills would
otherwise make one row taller than the rest of the page. */ otherwise make one row taller than the rest of the page. */
const SKILL_CHIPS = 4 const SKILL_CHIPS = 4
const SUGGESTED_CHIPS = 3
const EMPTY_FILTERS = { source: '', band: '', years: '' } 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({ const res = await candidatesApi.listCvBank({
top: limit, top: limit,
skip: offset, skip: offset,
@ -66,7 +63,6 @@ async function fetchBank({ limit, offset, search, filters, jobPostId }) {
source: filters.source || undefined, source: filters.source || undefined,
band: filters.band || undefined, band: filters.band || undefined,
minYears: filters.years ? Number(filters.years) : undefined, minYears: filters.years ? Number(filters.years) : undefined,
jobPostId: jobPostId || undefined,
}) })
const rows = Array.isArray(res?.data) ? res.data : [] const rows = Array.isArray(res?.data) ? res.data : []
return { 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 }) { function AtsCell({ score, recommendation }) {
if (score == null) return <span className="text-muted text-sm">Not scored</span> if (score == null) return <span className="text-muted text-sm">Not scored</span>
return ( 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() { export default function CvBank() {
const { toast } = useToast() const { toast } = useToast()
const qc = useQueryClient() const qc = useQueryClient()
const navigate = useNavigate() const navigate = useNavigate()
const [params, setParams] = useSearchParams()
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -126,19 +130,8 @@ export default function CvBank() {
const [skip, setSkip] = useState(0) const [skip, setSkip] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE) const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [preview, setPreview] = useState(null) // { name, url } object URL we own const [preview, setPreview] = useState(null) // { name, url } object URL we own
const [scoreFor, setScoreFor] = useState(null) const [pickingRow, setPickingRow] = useState(null)
const [assignFor, setAssignFor] = useState(null) const [pickedById, setPickedById] = useState({})
/* 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)
}
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS) const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
@ -147,15 +140,12 @@ export default function CvBank() {
useEffect(() => { setSkip(0) }, [search]) useEffect(() => { setSkip(0) }, [search])
const bankQuery = useQuery({ const bankQuery = useQuery({
queryKey: qk.cvBank.list({ 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, jobPostId }), 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 rows = useMemo(() => bankQuery.data?.rows ?? [], [bankQuery.data])
const total = bankQuery.data?.total ?? 0 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 pages = Math.max(1, Math.ceil(total / pageSize))
const from = total ? skip + 1 : 0 const from = total ? skip + 1 : 0
@ -173,8 +163,9 @@ export default function CvBank() {
{ key: 'title', label: 'Role', sortable: true }, { key: 'title', label: 'Role', sortable: true },
{ key: 'years', label: 'Years', sortable: true }, { key: 'years', label: 'Years', sortable: true },
{ key: 'skills', label: 'Skills', sortable: false }, { 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: 'aiScore', label: 'ATS', sortable: true },
{ key: 'job', label: 'Job', sortable: false },
{ key: 'added', label: 'Added', sortable: true }, { key: 'added', label: 'Added', sortable: true },
{ key: 'actions', label: '', sortable: false }, { key: 'actions', label: '', sortable: false },
], []) ], [])
@ -196,33 +187,32 @@ export default function CvBank() {
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'), onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
}) })
const scoring = useMutation({ const runAts = useMutation({
mutationFn: ({ jobId, ids }) => candidatesApi.scoreCvBank(jobId, ids), mutationFn: async ({ row, jobId }) => {
onSuccess: (res) => { if (row.isStoredCv) {
const row = Array.isArray(res?.data) ? res.data[0] : null await candidatesApi.assignMatchingJob(row.recordId, jobId)
if (row?.status === 'completed') { return candidatesApi.scoreCvBank(jobId, [row.recordId])
toast(`Scored ${row.match_score}/100 — the result is on Candidates now`, 'success') }
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 { } else {
toast(`Could not score the CV${row?.error_code ? `${row.error_code}` : ''}`, 'warning') toast(`Could not score the CV${row?.error_code ? `${row.error_code}` : ''}`, 'warning')
} }
qc.invalidateQueries({ queryKey: qk.cvBank.all() }) qc.invalidateQueries({ queryKey: qk.cvBank.all() })
qc.invalidateQueries({ queryKey: qk.candidates.all() }) qc.invalidateQueries({ queryKey: qk.candidates.all() })
setScoreFor(null) qc.invalidateQueries({ queryKey: qk.pipeline.all() })
}, },
onError: (err) => toast(friendlyAuthError(err, 'Scoring failed'), 'error'), 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) { async function view(row) {
if (!row.isStoredCv) { if (!row.isStoredCv) {
if (row.userId) navigate(`/candidate/${row.userId}`) if (row.userId) navigate(`/candidate/${row.userId}`)
@ -269,7 +259,7 @@ export default function CvBank() {
await exportStyledXlsx({ await exportStyledXlsx({
filename: `cv-bank-${new Date().toISOString().slice(0, 10)}`, filename: `cv-bank-${new Date().toISOString().slice(0, 10)}`,
title: 'CV Bank', 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: [ columns: [
{ header: 'Name', key: 'name', width: 26 }, { header: 'Name', key: 'name', width: 26 },
{ header: 'Email', key: 'email', width: 30 }, { header: 'Email', key: 'email', width: 30 },
@ -278,12 +268,11 @@ export default function CvBank() {
{ header: 'Company', key: 'company', width: 24 }, { header: 'Company', key: 'company', width: 24 },
{ header: 'Years', key: 'years', width: 8 }, { header: 'Years', key: 'years', width: 8 },
{ header: 'Skills', key: 'skills', width: 42 }, { 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: 'ATS', key: 'ats', width: 10 },
{ header: 'Scored job', key: 'scoredJob', width: 24 },
{ header: 'Added', key: 'added', width: 12 }, { 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) => ({ rows: rows.map((r) => ({
name: r.name, name: r.name,
email: r.email || '', email: r.email || '',
@ -292,8 +281,9 @@ export default function CvBank() {
company: r.company || '', company: r.company || '',
years: r.years ?? '', years: r.years ?? '',
skills: r.skills.join(', '), skills: r.skills.join(', '),
match: r.rankScore ?? '', suggested: r.suggestedJobs.map((j) => j.title).filter(Boolean).join(', '),
ats: r.aiScore ?? '', ats: r.aiScore ?? '',
scoredJob: jobFor(r, pickedById)?.title || '',
added: r.added ? r.added.toLocaleDateString() : '', added: r.added ? r.added.toLocaleDateString() : '',
})), })),
}) })
@ -309,7 +299,7 @@ export default function CvBank() {
title="CV Bank" title="CV Bank"
sub={ sub={
bankQuery.isSuccess 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' : 'CVs held for future roles'
} }
actions={<> actions={<>
@ -337,20 +327,6 @@ export default function CvBank() {
<Icon name="filter" /> Filters <Icon name="filter" /> Filters
</button> </button>
<div className="spacer" /> <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> </div>
{showFilters && ( {showFilters && (
@ -418,100 +394,124 @@ export default function CvBank() {
</td> </td>
</tr> </tr>
) : ( ) : (
t.pageRows.map((r) => ( t.pageRows.map((r) => {
<tr key={r.id}> const picked = jobFor(r, pickedById)
<td> const scoringThis = runAts.isPending && runAts.variables?.row?.id === r.id
<div className="user-cell"> const runDisabled = !r.canRunAts || !picked || runAts.isPending
<Avatar name={r.name} initials={initialsOf(r.name)} color={avatarColor(r.name)} /> const runTitle = !r.canRunAts
<div style={{ minWidth: 0 }}> ? 'Silver medalists are scored from their application — this row has no inbox CV to score'
<div className="cell-primary">{r.name}</div> : !picked
<div className="cell-sub">{r.email || r.fileName || 'No email detected'}</div> ? '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>
</div> </td>
</td> <td>
<td> <Badge className={SOURCE_BADGE[r.source] || 'b-gray'}>{r.sourceLabel}</Badge>
<Badge className={SOURCE_BADGE[r.source] || 'b-gray'}>{r.sourceLabel}</Badge> {r.lastJobTitle && (
{r.lastJobTitle && ( <div className="cell-sub cell-clip" title={r.lastJobTitle}>
<div className="cell-sub cell-clip" title={r.lastJobTitle}> applied for {r.lastJobTitle}
applied for {r.lastJobTitle} </div>
</div> )}
)} {r.expiresAt && (
{r.expiresAt && ( <div className="cell-sub">{candidatesApi.expiryLabel(r.expiresAt)}</div>
<div className="cell-sub">{candidatesApi.expiryLabel(r.expiresAt)}</div> )}
)} </td>
</td> <td>
<td> <div className="text-sm cell-clip" title={r.title || undefined}>{r.title || '—'}</div>
<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>}
{r.company && <div className="cell-sub cell-clip" title={r.company}>{r.company}</div>} </td>
</td> <td>
<td> <span className="text-sm">{r.years == null ? '—' : r.years}</span>
<span className="text-sm">{r.years == null ? '—' : r.years}</span> </td>
</td> <td>
<td> {r.skills.length ? (
{r.skills.length ? ( <div className="k-tags">
<div className="k-tags"> {r.skills.slice(0, SKILL_CHIPS).map((s) => (
{r.skills.slice(0, SKILL_CHIPS).map((s) => ( <span className="tag" key={s}>{s}</span>
<span className="tag" key={s}>{s}</span> ))}
))} {r.skills.length > SKILL_CHIPS && (
{r.skills.length > SKILL_CHIPS && ( <span className="tag" title={r.skills.slice(SKILL_CHIPS).join(', ')}>
<span className="tag" title={r.skills.slice(SKILL_CHIPS).join(', ')}> +{r.skills.length - SKILL_CHIPS}
+{r.skills.length - SKILL_CHIPS} </span>
</span> )}
)} </div>
</div> ) : (
) : ( <span className="text-muted text-sm">None extracted</span>
<span className="text-muted text-sm">None extracted</span> )}
)} </td>
</td> <td><SuggestedJobsCell jobs={r.suggestedJobs} /></td>
<td><MatchCell rank={r.rankScore} hasJob={Boolean(jobPostId)} /></td> <td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td> <td>
<td> <div className="flex items-center gap-8" style={{ flexWrap: 'wrap' }}>
<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>
<button <button
className="act-btn" type="button"
data-tip="Score against a job" className="btn btn-secondary btn-sm"
aria-label="Score this CV against a job" onClick={() => setPickingRow(r)}
onClick={() => setScoreFor(r)} title={picked ? `ATS job: ${picked.title}` : 'Pick a job to score against'}
> >
<Icon name="target" /> {picked?.title || 'Pick a job'}
</button> </button>
<button <button
className="act-btn" type="button"
data-tip="Assign to a job" className="btn btn-primary btn-sm"
aria-label="Assign this CV to a job" disabled={runDisabled}
onClick={() => setAssignFor(r)} title={runTitle}
>
<Icon name="briefcase" />
</button>
<button
className="act-btn"
data-tip="Remove"
aria-label="Remove CV from bank"
disabled={removing.isPending}
onClick={() => { onClick={() => {
if (window.confirm(`Remove “${r.fileName || r.name}” from the CV Bank? The file is deleted permanently.`)) { if (!picked || runDisabled) return
removing.mutate(r.recordId) runAts.mutate({
} row: r,
jobId: picked.id,
jobTitle: picked.title,
})
}} }}
> >
<Icon name="trash" /> {scoringThis ? 'Scoring…' : 'Run ATS'}
</button> </button>
</>)} </div>
</div> </td>
</td> <td>
</tr> <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> </tbody>
</table> </table>
@ -533,36 +533,21 @@ export default function CvBank() {
</div> </div>
<p className="text-muted text-sm mt-18"> <p className="text-muted text-sm mt-18">
<Icon name="info" /> <strong>Match</strong> is free keyword overlap against the selected <Icon name="info" /> Pick a job on a row, then <strong>Run ATS</strong> for a
job it orders this list, it does not assess anyone. <strong>ATS</strong> is a real real scored result. Speculative CVs are linked to that job; silver medalists
scored result and only appears once someone runs one. stay on their existing application.
</p> </p>
{scoreFor && ( {pickingRow && (
<JobPickerModal <PickRoleModal
title="Score against a job" onClose={() => setPickingRow(null)}
subtitle={`Run the real ATS score for ${scoreFor.name}`} onPick={(post) => {
note="This calls the scoring model and costs money. The result lands in Candidates like any other scored CV." if (!post?.id) return
confirmLabel="Score CV" setPickedById((m) => ({
jobs={jobs} ...m,
defaultJobId={jobPostId} [pickingRow.id]: { id: String(post.id), title: post.title || 'Selected job' },
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 })}
/> />
)} )}
@ -598,42 +583,3 @@ function Facet({ label, value, onChange, any, options, labels }) {
</div> </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>
)
}