/candidate page is done and completedc
parent
a49022b757
commit
4d56f987f7
|
|
@ -135,6 +135,7 @@ async def create_manual_candidate(
|
|||
candidate_phone: str | None = Form(None),
|
||||
job_post_id: str | None = Form(None),
|
||||
current_company: str | None = Form(None),
|
||||
current_position: str | None = Form(None),
|
||||
platform: str | None = Form(None),
|
||||
experience: str | None = Form(None),
|
||||
status: str | None = Form(None),
|
||||
|
|
@ -159,6 +160,7 @@ async def create_manual_candidate(
|
|||
candidate_phone=candidate_phone,
|
||||
job_post_id=job_post_id,
|
||||
current_company=current_company,
|
||||
current_position=current_position,
|
||||
platform=platform,
|
||||
experience=experience,
|
||||
status=status,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
||||
full_text: str = Field(default="")
|
||||
current_company: str = Field(default="")
|
||||
# Candidate's role at that company (e.g. "Senior Merchandiser"). Distinct
|
||||
# from job_posts.title — that is the role they applied to, not their own.
|
||||
# Same ALTER-on-a-populated-table reasoning as referral_by below.
|
||||
current_position: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
platform: str = Field(default="")
|
||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
|
|
@ -98,6 +102,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
job_post_id=cls._as_uuid(fields.get("job_post_id")),
|
||||
full_text=fields.get("full_text") or "",
|
||||
current_company=(fields.get("current_company") or "").strip(),
|
||||
current_position=(fields.get("current_position") or "").strip(),
|
||||
user_id=user.id,
|
||||
platform=(fields.get("platform") or "").strip(),
|
||||
created_by=cls._as_uuid(fields.get("created_by")),
|
||||
|
|
@ -112,6 +117,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def get_by_user_id(cls, session: AsyncSession, user_id):
|
||||
uid = cls._as_uuid(user_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.user_id == uid).order_by(cls.created_at.desc())
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
class Candidates(SQLModel, table=True):
|
||||
"""One scored (or failed-to-score) CV against one job post.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from job.candidate.plugins import documents_from_message, source_from_message_to
|
|||
from job.interviews.serializers import serialize_interview
|
||||
from job.activity.serializers import serialize_activity
|
||||
from job.feedback.serializers import serialize_feedback
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
|
||||
def serialize_candidate(row) -> dict:
|
||||
return {
|
||||
|
|
@ -42,6 +43,7 @@ def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
|||
"job_post_id":str(row.job_post_id) if row.job_post_id else None,
|
||||
"full_text":row.full_text,
|
||||
"current_company":row.current_company,
|
||||
"current_position":row.current_position,
|
||||
"user_id":str(row.user_id) if row.user_id else None,
|
||||
"platform":row.platform,
|
||||
"created_by":str(row.created_by) if row.created_by else None,
|
||||
|
|
@ -118,3 +120,64 @@ def serialize_candidate_profile(
|
|||
"notes": [],
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
||||
"""Same key vocabulary as serialize_candidate_profile(detail=True).
|
||||
|
||||
Manual uploads never go through inbox, so current_position maps onto
|
||||
current_title here and the agent/ATS fields stay empty.
|
||||
"""
|
||||
job_payload = serialize_job_post(job_post) if job_post else None
|
||||
created = row.created_at.isoformat() if row.created_at else None
|
||||
file_name = (row.file_name or "").strip() or None
|
||||
file_path = (row.file_path or "").strip() or None
|
||||
documents = [{"name": file_name or "", "path": file_path or ""}] if (file_name or file_path) else []
|
||||
company = (row.current_company or "").strip() or None
|
||||
position = (row.current_position or "").strip() or None
|
||||
return {
|
||||
"inbox_id": None,
|
||||
"user_id": str(user.id) if user else (str(row.user_id) if row.user_id else None),
|
||||
"name": (user.name if user else None) or row.candidate_name or None,
|
||||
"email": (user.email if user else None) or row.candidate_email or None,
|
||||
"is_active": user.is_active if user else None,
|
||||
"message_id": None,
|
||||
"created_at": created,
|
||||
"application_status": row.status or None,
|
||||
"experience": (row.experience or "").strip() or None,
|
||||
"current_employment": company,
|
||||
"current_title": position,
|
||||
"resume_text": row.full_text or None,
|
||||
"suggested_job_post_ids": [],
|
||||
"assigned_job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||
"match_summary": None,
|
||||
"match_reasoning": None,
|
||||
"match_status": None,
|
||||
"match_error": None,
|
||||
"matched_at": None,
|
||||
"job_posts": [job_payload] if job_payload else [],
|
||||
"favorite": None,
|
||||
"rating": None,
|
||||
"phone": (row.candidate_phone or "").strip() or None,
|
||||
"education": None,
|
||||
"currentCompany": company,
|
||||
"stage": row.status or None,
|
||||
"source": (row.platform or "").strip() or None,
|
||||
"applied": created,
|
||||
"documents": documents,
|
||||
"recruiter": job_payload.get("created_by_name") if job_payload else None,
|
||||
"recruiter_id": job_payload.get("created_by") if job_payload else None,
|
||||
"job_title": job_payload.get("title") if job_payload else None,
|
||||
"ai_score": None,
|
||||
"recommendation": None,
|
||||
"sub_scores": None,
|
||||
"interviews": [],
|
||||
"activity": [],
|
||||
"feedback": [],
|
||||
"notes": [],
|
||||
"assigned_job_post": job_payload,
|
||||
"matched_keywords": [],
|
||||
"missing_keywords": [],
|
||||
"summary_critique": None,
|
||||
"scored_at": None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ from job.candidate.plugins import (
|
|||
get_scoring_settings,
|
||||
normalize_spaced_text,
|
||||
)
|
||||
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile
|
||||
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
from job.candidate.serializers import serialize_manual_upload_candidate
|
||||
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
|
||||
from job.notes.serializers import serialize_note
|
||||
from job.candidate.plugins import extract_candidate_email
|
||||
from users.models import Users
|
||||
|
||||
load_dotenv()
|
||||
logger=logging.getLogger("job.candidate.views")
|
||||
|
|
@ -467,7 +467,7 @@ class CandidateView:
|
|||
scores.setdefault(row.inbox_message_id,[]).append(row)
|
||||
return scores
|
||||
|
||||
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None):
|
||||
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,current_position=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None):
|
||||
try:
|
||||
email=(candidate_email or "").strip().lower()
|
||||
if not email:
|
||||
|
|
@ -480,6 +480,7 @@ class CandidateView:
|
|||
"candidate_phone":(candidate_phone or "").strip(),
|
||||
"job_post_id":job_post_id,
|
||||
"current_company":(current_company or "").strip(),
|
||||
"current_position":(current_position or "").strip(),
|
||||
"platform":(platform or "").strip(),
|
||||
"experience":(experience or "").strip(),
|
||||
"status":(status or "").strip(),
|
||||
|
|
@ -504,7 +505,19 @@ class CandidateView:
|
|||
fetch_limit=1000 if detail else limit
|
||||
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search)
|
||||
if detail:
|
||||
return await self.attach_profile_detail(rows)
|
||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||
if records:
|
||||
return await self.attach_profile_detail(rows)
|
||||
# Manual uploads create users + manual_upload_candidate but no inbox
|
||||
# row — resolve the profile from that table instead of returning [].
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||
if not manual:
|
||||
return []
|
||||
user=await Users.get_user_by_id(self.session,user_id)
|
||||
job_post=None
|
||||
if manual.job_post_id:
|
||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
||||
return serialize_manual_candidate_profile(manual,user,job_post)
|
||||
return await self.attach_job_posts(rows)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ export function update(userId, payload) {
|
|||
* the recruiter typed — often someone with no account here.
|
||||
*/
|
||||
export function createManual({
|
||||
file, name, email, phone, jobPostId, company, source, experience, stage, referralBy,
|
||||
file, name, email, phone, jobPostId, company, currentPosition, source, experience, stage, referralBy,
|
||||
}) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
|
@ -234,6 +234,7 @@ export function createManual({
|
|||
put('candidate_phone', phone)
|
||||
put('job_post_id', jobPostId)
|
||||
put('current_company', company)
|
||||
put('current_position', currentPosition)
|
||||
put('platform', source)
|
||||
put('experience', experience)
|
||||
put('status', stage)
|
||||
|
|
|
|||
|
|
@ -511,7 +511,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
|
|||
|
||||
const form = useFormState({
|
||||
name: '', email: '', phone: '', job: '',
|
||||
experience: '3', company: '', source: sources[0], stage: stages[0],
|
||||
experience: '3', company: '', position: '', source: sources[0], stage: stages[0],
|
||||
referral: '',
|
||||
})
|
||||
|
||||
|
|
@ -573,6 +573,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
|
|||
phone: v.phone,
|
||||
jobPostId,
|
||||
company: v.company,
|
||||
currentPosition: v.position,
|
||||
source: v.source,
|
||||
experience: v.experience,
|
||||
stage: v.stage,
|
||||
|
|
@ -629,6 +630,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
|
|||
</div>
|
||||
<div className="form-field"><label>Experience (years)</label><input type="number" {...field('experience')} /></div>
|
||||
<div className="form-field"><label>Current Company</label><input {...field('company')} placeholder="Acme Inc." /></div>
|
||||
<div className="form-field"><label>Current Position</label><input {...field('position')} placeholder="Senior Merchandiser" /></div>
|
||||
<div className="form-field">
|
||||
<label>Source</label>
|
||||
<select {...field('source')}>{sources.map((s) => <option key={s}>{s}</option>)}</select>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,125 @@
|
|||
/* The profile modal for SCORED candidates (rows from /candidate/scored/fetch),
|
||||
used by Candidates.jsx. Distinct from CandidateProfile.jsx, which renders
|
||||
inbox-derived candidate profiles (userId, interviews, notes, feedback) for
|
||||
TalentPool. The two data shapes share almost no fields, hence two modals. */
|
||||
/* 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 { useState } from 'react'
|
||||
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 scored = c.scoringStatus === 'completed'
|
||||
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={c.filename}
|
||||
subtitle={view.subtitle}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
|
|
@ -34,23 +132,21 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
|
|||
}
|
||||
>
|
||||
<div className="profile-hero">
|
||||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
|
||||
<Avatar name={view.name} initials={initialsOf(view.name)} color={avatarColor(view.name)} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ph-name">{c.name}</div>
|
||||
<div className="ph-role">
|
||||
{c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''}
|
||||
</div>
|
||||
<div className="ph-name">{view.name}</div>
|
||||
<div className="ph-role">{view.roleLine}</div>
|
||||
<div className="ph-tags">
|
||||
{c.scoringStatus && (scored ? <Badge className="b-green">Scored</Badge> : <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>)}
|
||||
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
|
||||
{c.experience != null && (
|
||||
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
|
||||
{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>
|
||||
{c.aiScore != null && (
|
||||
{view.aiScore != null && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
<ScoreChip score={view.aiScore} />
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -61,77 +157,77 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
|
|||
</div>
|
||||
|
||||
<div className="tab-pane active">
|
||||
{tab === 'Overview' && (
|
||||
<>
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<div className="info-item"><div className="il">Scored For</div><div className="iv">{jobTitle ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience != null ? `${c.experience} years` : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
|
||||
<div className="info-item"><div className="il">Added On</div><div className="iv">{c.applied ? fmtDate(c.applied) : '—'}</div></div>
|
||||
</div>
|
||||
{scored && (
|
||||
{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 style={LABEL}>Matched Skills</div>
|
||||
<div className="k-tags">
|
||||
{c.matchedSkills.length
|
||||
? c.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)
|
||||
<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 === 'Scoring' && (
|
||||
scored ? (
|
||||
<>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
|
||||
<p className="text-muted" style={{ marginBottom: 18 }}>{c.critique ?? '—'}</p>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>
|
||||
Matched Skills ({c.matchedSkills.length})
|
||||
</div>
|
||||
<div className="k-tags" style={{ marginBottom: 16 }}>
|
||||
{c.matchedSkills.length
|
||||
? c.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 ({c.missingSkills.length})
|
||||
</div>
|
||||
<div className="k-tags">
|
||||
{c.missingSkills.length
|
||||
? c.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">
|
||||
{c.errorMessage ?? 'This CV could not be processed.'}
|
||||
</EmptyState>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'File' && (
|
||||
<div className="info-grid">
|
||||
<div className="info-item"><div className="il">File Name</div><div className="iv">{c.filename}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
|
||||
{c.inboxMessageId && (
|
||||
<div className="info-item"><div className="il">Inbox Message</div><div className="iv">{c.inboxMessageId}</div></div>
|
||||
)}
|
||||
{!scored && (
|
||||
<>
|
||||
<div className="info-item"><div className="il">Error</div><div className="iv">{c.errorCode ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Detail</div><div className="iv">{c.errorMessage ?? '—'}</div></div>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
{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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue