/candidate page is done and completedc

pull/13/head
ahmed.mujtaba 2026-08-12 18:11:42 +05:00
parent a49022b757
commit 4d56f987f7
7 changed files with 282 additions and 90 deletions

View File

@ -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,

View File

@ -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.

View File

@ -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,
}

View File

@ -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

View File

@ -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)

View File

@ -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>

View File

@ -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>
)