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

235 lines
10 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 TalentPool modal. */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
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'
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
}
function useCandidateDetail(userId) {
return useQuery({
queryKey: qk.candidates.detail(userId),
queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null,
enabled: Boolean(userId),
})
}
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 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 matchSummary = live?.match_summary ?? null
const messageId = live?.message_id ?? c.inboxMessageId ?? null
const aiScore = 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 = live
? Boolean(live.scored_at || live.ai_score != null)
: c.scoringStatus === 'completed'
return {
name: c.name,
email: c.email,
applied: c.applied,
currentTitle,
currentCompany,
experience,
source,
filename,
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, 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>
</div>
{view.aiScore != null && (
<div style={{ textAlign: 'center' }}>
<ScoreChip score={view.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>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">
{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>
{view.scored && (
<>
<div style={LABEL}>Matched Skills</div>
<div className="k-tags">
{view.matchedSkills.length
? view.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)
: <span className="text-muted"></span>}
</div>
</>
)}
</>
)}
{tab === 'Scoring' && (
view.scored ? (
<>
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
<p className="text-muted" style={{ marginBottom: 18 }}>{view.critique ?? '—'}</p>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({view.matchedSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{view.matchedSkills.length
? view.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Missing Skills ({view.missingSkills.length})
</div>
<div className="k-tags">
{view.missingSkills.length
? view.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
</>
) : (
<EmptyState icon="target" title="Not scored yet">
This candidate has not been scored against a job post.
</EmptyState>
)
)}
{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>
{view.messageId && (
<div className="info-item"><div className="il">Inbox Message</div><div className="iv">{view.messageId}</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>
)}
</>)}
</div>
</Modal>
)
}