From 9ffa1ef6739a360aa5a38b0f3444791bf8576330 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Mon, 10 Aug 2026 22:27:20 +0500 Subject: [PATCH] Phase 2: wire frontend candidate screens to the real scoring API Backend: GET /job/fetch (active job posts, job-board OR candidate viewers) and /candidate/fetch now works unscoped for the cross-job pool. Frontend: apiClient gains FormData support; new api/candidates.js with a shared snake->camel view mapper; CvImport is a real upload->score flow (job selector, PDF multipart to /candidate/score, per-file results, no more simulation); TalentPool and Candidates render the persisted pool with job/skill/source/ATS filters; the ATS modal shows the real critique and matched/missing skills; CandidateProfile keeps only tabs the backend can back. Screens hide affordances with no backing column instead of rendering placeholders (Inbox precedent). Co-Authored-By: Claude Fable 5 --- backend/job/app.py | 28 +- backend/job/candidate/models.py | 30 +- backend/job/candidate/views.py | 10 +- frontend/src/api/candidates.js | 82 +++ frontend/src/lib/apiClient.js | 7 +- frontend/src/lib/queryKeys.js | 9 + frontend/src/screens/CandidateProfile.jsx | 302 +++------- frontend/src/screens/Candidates.jsx | 650 ++++++++-------------- frontend/src/screens/CvImport.jsx | 364 +++++------- frontend/src/screens/TalentPool.jsx | 131 +++-- 10 files changed, 686 insertions(+), 927 deletions(-) create mode 100644 frontend/src/api/candidates.js diff --git a/backend/job/app.py b/backend/job/app.py index 2eefa7f..1117c4f 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -6,6 +6,8 @@ from job.candidate.views import CandidateScoring,FileRead from sqlalchemy.ext.asyncio import AsyncSession from users.permissions import PermissionTag, require_permission from job.job_post.views import JobPost,JobPostCreate +from job.job_post.models import JobPosts +from job.job_post.serializers import serialize_job_post import logging from job.job_post.plugins import PlatformAlias from fastapi import UploadFile, File, Form @@ -146,11 +148,12 @@ async def score_inbox_candidates( @router.get("/candidate/fetch") async def fetch_candidates( - job_id: str = Query(...), + job_id: str = Query(None), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), session: AsyncSession = Depends(get_session), ): - """Persisted leaderboard for a job: completed by score desc, failures last.""" + """Persisted leaderboard: completed by score desc, failures last. Without job_id + returns the whole pool across jobs.""" try: service=CandidateScoring(session=session) data=await service.fetch_candidates(job_id) @@ -161,6 +164,27 @@ async def fetch_candidates( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/job/fetch") +async def fetch_jobs( + current_user: dict = Depends( + require_permission( + PermissionTag.JOB_BOARD_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False + ) + ), + session: AsyncSession = Depends(get_session), +): + """Active job posts, for pickers and boards. Either job-board or candidate viewers + may list them — recruiters scoring CVs need a job to score against.""" + try: + rows=await JobPosts.get_active_job_posts(session) + data=[serialize_job_post(row) for row in rows] + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/candidate/fetch_by_id") async def fetch_candidate_by_id( candidate_id: str = Query(...), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 3f5bb4f..b006591 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -67,20 +67,24 @@ class Candidates(SQLModel, table=True): return result.scalars().first() @classmethod - async def get_candidates_by_job(cls, session: AsyncSession, job_id: str): - """Leaderboard order: completed by score desc, failures last, ties stable.""" - uid = cls._as_uuid(job_id) - if uid is None: - return [] - result = await session.execute( - select(cls) - .where(cls.job_id == uid) - .order_by( - cls.status.asc(), # "completed" < "failed" - cls.match_score.desc().nulls_last(), - cls.created_at.asc(), - ) + async def get_candidates_by_job(cls, session: AsyncSession, job_id: str | None = None): + """Leaderboard order: completed by score desc, failures last, ties stable. + + job_id=None returns the whole pool across jobs (same ordering) for the + frontend's unscoped Candidates/Talent Pool views. + """ + statement = select(cls) + if job_id is not None: + uid = cls._as_uuid(job_id) + if uid is None: + return [] + statement = statement.where(cls.job_id == uid) + statement = statement.order_by( + cls.status.asc(), # "completed" < "failed" + cls.match_score.desc().nulls_last(), + cls.created_at.asc(), ) + result = await session.execute(statement) return result.scalars().all() @classmethod diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index c97cf82..aed83bb 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -160,10 +160,12 @@ class CandidateScoring: raise HTTPException(status_code=400,detail="No attachments found for the given message(s)") return await self._score_and_persist(job_id,sources,"inbox",current_user) - async def fetch_candidates(self,job_id): - job=await JobPosts.get_job_post_by_id(self.session,job_id) - if job is None or job.is_deleted: - raise HTTPException(status_code=404,detail="Job post not found") + async def fetch_candidates(self,job_id=None): + # job_id omitted -> the whole pool across jobs (frontend Candidates/TalentPool). + if job_id is not None: + job=await JobPosts.get_job_post_by_id(self.session,job_id) + if job is None or job.is_deleted: + raise HTTPException(status_code=404,detail="Job post not found") rows=await Candidates.get_candidates_by_job(self.session,job_id) return [serialize_candidate(row) for row in rows] diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js new file mode 100644 index 0000000..eff3388 --- /dev/null +++ b/frontend/src/api/candidates.js @@ -0,0 +1,82 @@ +/* ============================================================ + candidates.js — ATS scoring endpoints (backend/job/app.py). + + Same conventions as inbox.js: one named export per endpoint, no hooks, + camelCase params mapped to snake_case at the call boundary, and every + function returns the parsed {data, total, status_code} envelope. + ============================================================ */ + +import { request } from '../lib/apiClient' + +/** Active job posts for pickers. Needs job_board.view OR candidates.view. */ +export function listJobs() { + return request('/job/fetch') +} + +/** + * Persisted scoring leaderboard. Needs candidates.view. + * Omit jobId for the whole pool across jobs; rows are ordered completed-by- + * score-desc, then failed rows. + */ +export function listCandidates({ jobId } = {}) { + return request('/candidate/fetch', { params: { job_id: jobId } }) +} + +/** One candidate row by id. Needs candidates.view. 404s on unknown ids. */ +export function getCandidate(candidateId) { + return request('/candidate/fetch_by_id', { params: { candidate_id: candidateId } }) +} + +/** + * Score uploaded CV PDFs against a job post. Needs candidates.create. + * Multipart: unreadable/oversized/non-PDF files come back as rows with + * status "failed" instead of failing the batch. Re-scoring identical bytes + * against the same job updates the existing row (no duplicates). + */ +export function scoreUploads(jobId, files) { + const form = new FormData() + form.append('job_id', jobId) + for (const file of files) form.append('files', file, file.name) + return request('/candidate/score', { method: 'POST', body: form }) +} + +/** + * Score the decoded attachments of inbox messages against a job post. + * Needs candidates.create. messageIds are inbox_messages PK uuids (the `id` + * field the inbox list returns), not Graph message ids. + */ +export function scoreInbox(jobId, messageIds) { + return request('/candidate/score_inbox', { + method: 'POST', + body: { job_id: jobId, message_ids: messageIds }, + }) +} + +/** + * Shared snake_case → camelCase view-model mapper for candidate rows, so the + * three candidate screens agree on field names. Fields the backend does not + * store (email, phone, stage, education…) are deliberately absent — screens + * hide those affordances rather than render placeholders (Inbox precedent). + */ +export function toCandidateView(row) { + const name = row.candidate_name || row.filename || 'Unknown' + return { + id: row.id, + jobId: row.job_id, + name, + filename: row.filename, + source: row.source, // 'upload' | 'inbox' + currentTitle: row.job_title ?? null, + currentCompany: row.current_company ?? null, + experience: row.years_experience ?? null, + aiScore: row.match_score ?? null, + matchedSkills: Array.isArray(row.matched_keywords) ? row.matched_keywords : [], + missingSkills: Array.isArray(row.missing_keywords) ? row.missing_keywords : [], + critique: row.summary_critique ?? null, + scoringStatus: row.status, // 'completed' | 'failed' + errorCode: row.error_code ?? null, + errorMessage: row.error_message ?? null, + applied: row.created_at ? new Date(row.created_at) : null, + inboxMessageId: row.inbox_message_id ?? null, + } +} diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index 261b2bb..02d1505 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -51,14 +51,17 @@ export async function request( const send = async () => { const headers = { Accept: 'application/json' } - if (body != null) headers['Content-Type'] = 'application/json' + // FormData bodies (file uploads) set their own multipart boundary — adding a + // Content-Type here would break the request, and they must not be stringified. + const isForm = typeof FormData !== 'undefined' && body instanceof FormData + if (body != null && !isForm) headers['Content-Type'] = 'application/json' const bearer = token ?? (auth ? getAccessToken() : null) if (bearer) headers.Authorization = `Bearer ${bearer}` return fetch(buildUrl(path, params), { method, headers, signal, - body: body != null ? JSON.stringify(body) : undefined, + body: body == null ? undefined : isForm ? body : JSON.stringify(body), }) } diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 2a4d330..a3b0548 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -22,6 +22,15 @@ export const qk = { applications: (p = {}) => ['mailbox', 'applications', p], message: (id) => ['mailbox', 'message', id], }, + jobs: { + all: () => ['jobs'], + list: () => ['jobs', 'list'], + }, + candidates: { + all: () => ['candidates'], + list: (p = {}) => ['candidates', 'list', p], + detail: (id) => ['candidates', 'detail', id], + }, // --- seed-backed buckets --- // These are not "server state" — the cache IS the store for them, so every diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index cf837a9..839ead5 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -1,72 +1,58 @@ -/* The 8-tab candidate profile modal, split out of Candidates.jsx — it was the - single largest block in js/candidates.js and deserves its own file. */ +/* The candidate profile modal, on live backend data. Tabs that had no backing + store (notes, documents, feedback, fabricated timelines) are gone — what + remains is exactly what the scoring engine knows about the candidate. */ -import { useMemo, useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' import Modal from '../ui/Modal' import { Tabs } from '../ui/Tabs' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' -import { useToast } from '../ui/Toast' -import { seedQuery } from '../data/seedQueries' -import { companies, fmtDate, moneyK, pick } from '../data/seed' +import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed' -const TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback'] +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' } -export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) { - const { toast } = useToast() +export default function CandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) { const [tab, setTab] = useState('Overview') - const { data: interviews = [] } = useQuery(seedQuery('interviews')) - const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) - - // The prototype called DB.pick() inline while rendering, so the "previous - // employer" changed every repaint. Fixed per candidate. - const priorCompany = useMemo(() => pick(companies), []) - - const candidateInterviews = interviews.filter((i) => i.candidateId === c.id) + const scored = c.scoringStatus === 'completed' return ( - - - + } >
- +
{c.name}
-
{c.currentTitle} at {c.currentCompany}
+
+ {c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''} +
- {c.stage} {c.source} - {c.experience} yrs exp + {scored ? Scored : {c.errorCode ?? 'Failed'}} + {SOURCE_LABEL[c.source] ?? c.source} + {c.experience != null && ( + {c.experience} yrs exp + )}
-
- -
AI Match
-
+ {c.aiScore != null && ( +
+ +
AI Match
+
+ )}
@@ -77,200 +63,74 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT {tab === 'Overview' && ( <>
-
Email
{c.email}
-
Phone
{c.phone}
-
Location
{c.location}
-
Applied For
{c.jobTitle}
-
Current Company
{c.currentCompany}
-
Experience
{c.experience} years
-
Education
{c.education}
-
Source
{c.source}
-
Recruiter
{c.recruiter}
-
Applied On
{fmtDate(c.applied)}
-
Expected Salary
{moneyK(c.salary)}
-
Rating
⭐ {c.rating} / 5.0
+
Scored For
{jobTitle ?? '—'}
+
Current Title
{c.currentTitle ?? '—'}
+
Current Company
{c.currentCompany ?? '—'}
+
Experience
{c.experience != null ? `${c.experience} years` : '—'}
+
Source
{SOURCE_LABEL[c.source] ?? c.source}
+
Added On
{c.applied ? fmtDate(c.applied) : '—'}
-
Skills
-
{c.skills.map((s) => {s})}
+ {scored && ( + <> +
Matched Skills
+
+ {c.matchedSkills.length + ? c.matchedSkills.map((s) => {s}) + : } +
+ + )} )} - {tab === 'Resume' && ( - <> -
-
-

{c.name}

-

{c.currentTitle} · {c.location}

-
-
Summary
-

- Results-driven {c.currentTitle.toLowerCase()} with {c.experience} years of experience - across {c.department.toLowerCase()}. Passionate about building high-quality products - and collaborating with cross-functional teams. -

-
Experience
-
-
{c.currentTitle} — {c.currentCompany}
-
2021 – Present
-
-
-
Associate — {priorCompany}
-
2018 – 2021
-
-
Education
-
{c.education}
+ {tab === 'Scoring' && ( + scored ? ( + <> +
AI Assessment
+

{c.critique ?? '—'}

+
+ Matched Skills ({c.matchedSkills.length})
-
- - - )} - - {tab === 'Timeline' && ( -
- {[ - { icon: 'user-plus', title: 'Application received', meta: fmtDate(c.applied), desc: `Applied via ${c.source}` }, - { icon: 'star', title: 'AI screening completed', meta: '1 day later', desc: `Match score: ${c.aiScore}%` }, - { icon: 'phone', title: 'Recruiter screen', meta: '3 days later', desc: `Call with ${c.recruiter}` }, - { icon: 'calendar', title: 'Technical interview', meta: '1 week later', desc: 'Panel of 3 interviewers' }, - { icon: 'check', title: `Moved to ${c.stage}`, meta: 'Recently', desc: 'Current stage in pipeline' }, - ].map((e) => ( -
-
-
{e.title}
-
{e.meta}
-
{e.desc}
+
+ {c.matchedSkills.length + ? c.matchedSkills.map((s) => ( + {s} + )) + : }
- ))} -
- )} - - {tab === 'Interview' && ( - candidateInterviews.length ? ( -
- {candidateInterviews.map((iv) => ( -
- - - -
-
{iv.type}
-
{fmtDate(iv.when)} · {iv.meeting}
-
-
{iv.status}
-
- ))} -
+
+ Missing Skills ({c.missingSkills.length}) +
+
+ {c.missingSkills.length + ? c.missingSkills.map((s) => ( + {s} + )) + : None — full match} +
+ ) : ( - - Schedule an interview to get started. + + {c.errorMessage ?? 'This CV could not be processed.'} ) )} - {tab === 'Notes' && ( - <> -
- -