349 lines
14 KiB
JavaScript
349 lines
14 KiB
JavaScript
/* The profile modal for candidate rows on /candidates (identity from
|
|
/candidate/fetch/users, detail from GET /candidate/fetch?user_id=). Distinct
|
|
from CandidateProfile.jsx, which renders the 8-tab modal.
|
|
|
|
SCORING IS A THIRD, INDEPENDENT READ: GET /pipeline/candidate/score/fetch,
|
|
the candidate's current ats_results row. It has to be, because the detail
|
|
payload is not a source of scoring at all — serialize_candidate_profile and
|
|
serialize_manual_candidate_profile both hardcode ai_score, matched_keywords,
|
|
missing_keywords, summary_critique and scored_at to null/[]
|
|
(backend/job/candidate/serializers.py:116, 174-185). Reading scoring from it
|
|
meant every candidate on this screen showed "Not scored yet" however many
|
|
times the engine had actually scored them. */
|
|
|
|
import { useMemo, useState } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
|
|
import Modal from '../ui/Modal'
|
|
import OpenResumeButton from '../ui/OpenResumeButton'
|
|
import { Tabs } from '../ui/Tabs'
|
|
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
|
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
|
|
import { qk } from '../lib/queryKeys'
|
|
import { friendlyAuthError } from '../lib/errors'
|
|
import * as candidatesApi from '../api/candidates'
|
|
import * as pipelineApi from '../api/pipeline'
|
|
import * as s3Api from '../api/s3'
|
|
|
|
const TABS = ['Overview', 'Scoring', 'File']
|
|
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
|
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
|
|
|
|
/** Agent sentinels arrive as literal strings, not null — strip before display. */
|
|
const AGENT_SENTINELS = new Set([
|
|
'no company was mentioned',
|
|
'no education mentioned',
|
|
'no education mentioned.',
|
|
'no job position mentioned',
|
|
])
|
|
|
|
function stripSentinel(value) {
|
|
if (value == null) return null
|
|
const text = String(value).trim()
|
|
if (!text) return null
|
|
return AGENT_SENTINELS.has(text.toLowerCase()) ? null : text
|
|
}
|
|
|
|
/** Append a unit only for bare numeric counts ("5", "5+", "3.5"); leave "5+ years" alone. */
|
|
function formatExperience(value, unit) {
|
|
if (value == null || value === '') return null
|
|
const text = String(value).trim()
|
|
if (!text) return null
|
|
if (/^\d+(\.\d+)?\+?$/.test(text)) return `${text} ${unit}`
|
|
return text
|
|
}
|
|
|
|
/** ats_results.computed_at is an ISO string; fmtDate takes a Date. */
|
|
function fmtStamp(value) {
|
|
return fmtDate(value) || null
|
|
}
|
|
|
|
function useCandidateDetail(userId) {
|
|
return useQuery({
|
|
queryKey: qk.candidates.detail(userId),
|
|
queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null,
|
|
enabled: Boolean(userId),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The candidate's current ats_results row — the only scoring source this modal has.
|
|
*
|
|
* Sent WITHOUT job_post_id, for the same reason Talent Pool omits it: a row here
|
|
* is a candidate USER account with no job context (toCandidateUserView leaves
|
|
* jobId null), so pinning could only ever hide a score that exists under some
|
|
* other post. Unpinned, the endpoint answers with the newest current score the
|
|
* candidate has anywhere.
|
|
*
|
|
* Keyed by qk.pipeline.candidateScore, so re-opening the same candidate — or
|
|
* opening one already viewed in Talent Pool — repaints from cache.
|
|
*/
|
|
function useAtsResult(userId) {
|
|
return useQuery({
|
|
queryKey: qk.pipeline.candidateScore({ userId: userId ?? null }),
|
|
queryFn: () => pipelineApi.fetchCandidateScore({ userId }),
|
|
select: pipelineApi.toAtsScore,
|
|
enabled: Boolean(userId),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* job_post_id -> title, so the score reads as "scored against Senior Backend
|
|
* Engineer" rather than a uuid. Same query key and row shape as the Candidates
|
|
* screen's own jobs query, so this is a cache hit rather than a second request.
|
|
*/
|
|
export function useJobTitles() {
|
|
return useQuery({
|
|
queryKey: qk.jobPosts.list(),
|
|
queryFn: async () => {
|
|
const res = await candidatesApi.listJobs()
|
|
const rows = Array.isArray(res?.data) ? res.data : []
|
|
return rows.map((row) => ({ id: row.id, title: row.title }))
|
|
},
|
|
select: (rows) => new Map(rows.map((row) => [String(row.id), row.title])),
|
|
})
|
|
}
|
|
|
|
export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) {
|
|
const [tab, setTab] = useState('Overview')
|
|
const isLive = Boolean(c.userId)
|
|
const detail = useCandidateDetail(c.userId)
|
|
const live = detail.data ?? null
|
|
const ats = useAtsResult(c.userId)
|
|
const atsRow = ats.data ?? null
|
|
|
|
const view = useMemo(() => {
|
|
const currentTitle = stripSentinel(live?.current_title) ?? c.currentTitle ?? null
|
|
const currentCompany =
|
|
stripSentinel(live?.currentCompany ?? live?.current_employment) ?? c.currentCompany ?? null
|
|
const experience = live?.experience ?? c.experience ?? null
|
|
const source = live?.source ?? c.source ?? null
|
|
const filename =
|
|
live?.documents?.[0]?.name || c.filename || null
|
|
const filePath = s3Api.resumeKeyFrom(live) || c.filePath || null
|
|
const matchSummary = live?.match_summary ?? null
|
|
const messageId = live?.message_id ?? null
|
|
// ats_results wins over the detail payload's denormalised copy, because it is
|
|
// the row the copy is made from — and on this screen the copy is always null.
|
|
const aiScore = atsRow?.overall_score ?? live?.ai_score ?? c.aiScore ?? null
|
|
const matchedSkills = live?.matched_keywords ?? c.matchedSkills ?? []
|
|
const missingSkills = live?.missing_keywords ?? c.missingSkills ?? []
|
|
const critique = live?.summary_critique ?? c.critique ?? null
|
|
const errorCode = live?.error_code ?? c.errorCode ?? null
|
|
const errorMessage = live?.error_message ?? live?.match_error ?? c.errorMessage ?? null
|
|
const scoredFor = live?.job_title ?? jobTitle ?? null
|
|
const scored = atsRow != null || (live
|
|
? Boolean(live.scored_at || live.ai_score != null)
|
|
: c.scoringStatus === 'completed')
|
|
return {
|
|
band: atsRow?.band || null,
|
|
name: c.name,
|
|
email: c.email,
|
|
applied: c.applied,
|
|
currentTitle,
|
|
currentCompany,
|
|
experience,
|
|
source,
|
|
filename,
|
|
filePath,
|
|
matchSummary,
|
|
messageId,
|
|
aiScore,
|
|
matchedSkills: Array.isArray(matchedSkills) ? matchedSkills : [],
|
|
missingSkills: Array.isArray(missingSkills) ? missingSkills : [],
|
|
critique,
|
|
errorCode,
|
|
errorMessage,
|
|
scoredFor,
|
|
scored,
|
|
roleLine: [currentTitle, currentCompany].filter(Boolean).join(' at ') || '—',
|
|
experienceBadge: formatExperience(experience, 'yrs exp'),
|
|
experienceOverview: formatExperience(experience, 'years'),
|
|
sourceLabel: SOURCE_LABEL[source] ?? source ?? '—',
|
|
subtitle: filename || c.email || null,
|
|
}
|
|
}, [c, live, atsRow, jobTitle])
|
|
|
|
// enabled:false stays pending forever in TanStack v5 — short-circuit when no userId.
|
|
const guard = !isLive ? null
|
|
: detail.isPending ? (
|
|
<EmptyState icon="refresh" title="Loading candidate…">Fetching the full record.</EmptyState>
|
|
) : detail.isError ? (
|
|
<EmptyState icon="alert" title="Could not load this candidate">
|
|
{friendlyAuthError(detail.error, 'Please try again.')}
|
|
</EmptyState>
|
|
) : !live ? (
|
|
<EmptyState icon="user" title="No application on file">
|
|
This candidate has no inbox application or manual upload on record yet.
|
|
</EmptyState>
|
|
) : null
|
|
|
|
return (
|
|
<Modal
|
|
title="Candidate Profile"
|
|
subtitle={view.subtitle}
|
|
size="modal-lg"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
|
|
<Icon name="target" /> ATS Match
|
|
</button>
|
|
<button className="btn btn-primary" onClick={onClose}>Close</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="profile-hero">
|
|
<Avatar name={view.name} initials={initialsOf(view.name)} color={avatarColor(view.name)} className="avatar-lg" />
|
|
<div style={{ flex: 1 }}>
|
|
<div className="ph-name">{view.name}</div>
|
|
<div className="ph-role">{view.roleLine}</div>
|
|
<div className="ph-tags">
|
|
{view.scored && <Badge className="b-green">Scored</Badge>}
|
|
{view.source && <Badge className="b-gray">{view.sourceLabel}</Badge>}
|
|
{view.experienceBadge && (
|
|
<span className="badge b-plain b-indigo badge-plain">{view.experienceBadge}</span>
|
|
)}
|
|
</div>
|
|
{s3Api.canOpen(view.filePath) && (
|
|
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 10 }}>
|
|
<OpenResumeButton filePath={view.filePath} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{view.aiScore != null && (
|
|
<div style={{ textAlign: 'center' }}>
|
|
<ScoreChip score={Math.round(view.aiScore)} />
|
|
{/* The band replaces the static label only when the ATS row answered. */}
|
|
<div className="cell-sub" style={{ marginTop: 4 }}>{view.band || 'AI Match'}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ marginTop: 22 }}>
|
|
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
|
|
</div>
|
|
|
|
<div className="tab-pane active">
|
|
{/* Scoring sits OUTSIDE `guard`: it renders from the ats_results query, so
|
|
a slow or failed detail fetch must not blank it, and its own loading
|
|
and error states belong to that query. */}
|
|
{tab === 'Scoring' && <ScoringTab enabled={isLive} ats={ats} fallbackJobTitle={view.scoredFor} />}
|
|
|
|
{tab !== 'Scoring' && (guard ?? (<>
|
|
{tab === 'Overview' && (
|
|
<>
|
|
<div className="info-grid" style={{ marginBottom: 20 }}>
|
|
<div className="info-item"><div className="il">Scored For</div><div className="iv">{view.scoredFor ?? '—'}</div></div>
|
|
<div className="info-item"><div className="il">Current Title</div><div className="iv">{view.currentTitle ?? '—'}</div></div>
|
|
<div className="info-item"><div className="il">Current Company</div><div className="iv">{view.currentCompany ?? '—'}</div></div>
|
|
<div className="info-item"><div className="il">Experience</div><div className="iv">{view.experienceOverview ?? '—'}</div></div>
|
|
<div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
|
|
<div className="info-item"><div className="il">Added On</div><div className="iv">{view.applied ? fmtDate(view.applied) : '—'}</div></div>
|
|
</div>
|
|
{/* Gated on the list being non-empty, not on `scored`: the detail
|
|
payload never carries keywords, so keying it to the score would
|
|
render a heading over a dash for every scored candidate. */}
|
|
{view.matchedSkills.length > 0 && (
|
|
<>
|
|
<div style={LABEL}>Matched Skills</div>
|
|
<div className="k-tags">
|
|
{view.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{tab === 'File' && (
|
|
<>
|
|
<div className="info-grid">
|
|
<div className="info-item"><div className="il">File Name</div><div className="iv">{view.filename ?? '—'}</div></div>
|
|
<div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
|
|
<div className="info-item"><div className="il">Detail</div><div className="iv">{view.matchSummary ?? '—'}</div></div>
|
|
{view.errorCode && (
|
|
<div className="info-item"><div className="il">Error</div><div className="iv">{view.errorCode}</div></div>
|
|
)}
|
|
</div>
|
|
{s3Api.canOpen(view.filePath) && (
|
|
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 16 }}>
|
|
<OpenResumeButton filePath={view.filePath} />
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>))}
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The ats_results row, and nothing else.
|
|
*
|
|
* What that table stores IS the result: overall_score, band, the job post it was
|
|
* computed against, and when (backend/inbox/models.py::AtsResults). The matched
|
|
* and missing keywords and the critique live on the `candidates` table, which
|
|
* this endpoint does not join — so they are absent here rather than rendered as
|
|
* a heading over a dash.
|
|
*/
|
|
function ScoringTab({ enabled, ats, fallbackJobTitle }) {
|
|
// Before the early returns: hook order cannot depend on query state.
|
|
const { data: jobTitles } = useJobTitles()
|
|
const row = ats.data ?? null
|
|
|
|
// enabled:false stays pending forever in TanStack v5, so a candidate with no
|
|
// userId must short-circuit rather than spin.
|
|
if (!enabled) {
|
|
return (
|
|
<EmptyState icon="target" title="Not scored yet">
|
|
This candidate has no account to look a score up against.
|
|
</EmptyState>
|
|
)
|
|
}
|
|
if (ats.isPending) {
|
|
return <EmptyState icon="refresh" title="Loading score…">Fetching the ATS result.</EmptyState>
|
|
}
|
|
if (ats.isError) {
|
|
return (
|
|
<EmptyState icon="alert" title="Could not load the ATS result">
|
|
{friendlyAuthError(ats.error, 'Please try again.')}
|
|
</EmptyState>
|
|
)
|
|
}
|
|
if (!row) {
|
|
return (
|
|
<EmptyState icon="target" title="Not scored yet">
|
|
This candidate has not been scored against a job post.
|
|
</EmptyState>
|
|
)
|
|
}
|
|
|
|
// The uuid resolves to a title only once the jobs list is cached; the prop is
|
|
// the fallback, and it is '—' on this screen when the row carries no job.
|
|
const against = (row.job_post_id && jobTitles?.get(String(row.job_post_id))) || fallbackJobTitle || '—'
|
|
|
|
return (
|
|
<>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 18, marginBottom: 22 }}>
|
|
{/* overall_score is a float column; the ring and the label both want an int. */}
|
|
<ScoreChip score={Math.round(row.overall_score ?? 0)} />
|
|
<div>
|
|
<div className="iv" style={{ fontSize: 16, fontWeight: 600 }}>{row.band || 'Scored'}</div>
|
|
<div className="cell-sub">ATS match score out of 100</div>
|
|
</div>
|
|
</div>
|
|
<div className="info-grid">
|
|
<div className="info-item">
|
|
<div className="il">Scored Against</div>
|
|
<div className="iv">{against}</div>
|
|
</div>
|
|
<div className="info-item">
|
|
<div className="il">Scored On</div>
|
|
<div className="iv">{fmtStamp(row.computed_at) ?? '—'}</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|