235 lines
10 KiB
JavaScript
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>
|
|
)
|
|
}
|