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

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>
</>
)
}