From 7f3735362e21da187fac0563b687d6b9f9b9e651 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Sun, 30 Aug 2026 20:50:06 +0500 Subject: [PATCH] talentpool filter workds --- backend/employment_agent/decorators.py | 15 +- backend/employment_agent/prompt.py | 1 + backend/job/app.py | 66 +++- backend/job/candidate/models.py | 139 ++++++++- backend/job/candidate/serializers.py | 22 ++ backend/job/candidate/views.py | 53 +++- backend/job/job_post/serializers.py | 6 +- frontend/src/api/candidates.js | 28 ++ frontend/src/app/routes.js | 2 +- frontend/src/app/useShell.js | 15 +- frontend/src/lib/queryKeys.js | 2 + frontend/src/screens/CvImport.jsx | 10 +- frontend/src/screens/Matching.jsx | 413 +++++++------------------ frontend/src/screens/TalentPool.jsx | 39 ++- 14 files changed, 467 insertions(+), 344 deletions(-) diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py index 00d8336..563b5fd 100644 --- a/backend/employment_agent/decorators.py +++ b/backend/employment_agent/decorators.py @@ -60,6 +60,7 @@ def clamp_in_resume(key,sentinel): def _clean_linkedin(value,resume_text): + """Keep a LinkedIn URL only when the CV evidences it. Sentinel / invented → None.""" url=(value or "").strip() if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"): return None @@ -70,7 +71,19 @@ def _clean_linkedin(value,resume_text): return None if not lowered.startswith("http://") and not lowered.startswith("https://"): url="https://"+url.lstrip("/") - return url + text=(resume_text or "").strip() + if not text: + return url + from linkedin_utils import slug_from_url,slugs_from_text + agent_slug=slug_from_url(url) + if agent_slug: + return url if agent_slug in slugs_from_text(text) else None + if "lnkd.in" in lowered: + from linkedin_utils import profile_url_from_text + evidenced=profile_url_from_text(text) + if evidenced and "lnkd.in" in evidenced.lower(): + return evidenced + return None def _clean_phone(value,resume_text): diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py index 15f56c6..b453aff 100644 --- a/backend/employment_agent/prompt.py +++ b/backend/employment_agent/prompt.py @@ -39,6 +39,7 @@ linkedin_url (its own key — extract this separately from the other fields): - Copy the full slug. Never drop a trailing path segment. - Do not return a company page (linkedin.com/company/...), a search URL, or a URL that is not LinkedIn. - Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN} +- Never guess a slug or construct linkedin.com/in/ from the candidate's name. The stored value will be null when this sentinel is returned. phone (its own key — extract this separately; copy EVERY digit): - Return the candidate's own mobile / phone exactly as written, including country code when present. diff --git a/backend/job/app.py b/backend/job/app.py index d271172..1d46b48 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query,Response from fastapi.responses import FileResponse,JSONResponse from fastapi import HTTPException from db_setup import get_session -from job.candidate.views import CandidateScoring,FileRead,CandidateView +from job.candidate.views import CandidateScoring,FileRead,CandidateView,parse_linkedin_url_from_cv from job.interviews.views import Interview from job.notes.views import Note from job.activity.views import ActivityLog @@ -33,6 +33,11 @@ logger = logging.getLogger(__name__) router = APIRouter() +class MatchingAssign(BaseModel): + id: UUID + job_post_id: UUID | None = None + + class CandidateUpdate(BaseModel): favorite: bool | None = None rating: float | None = None @@ -297,6 +302,7 @@ async def cv_bank_upload( parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF text=parsed.get("text") or "" detected,_=extract_candidate_email(text) + parsed_linkedin=await parse_linkedin_url_from_cv(text) # Basename against both separator styles — a Windows client sends # C:\Users\x\cv.pdf whose PosixPath name is the whole string. original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf" @@ -308,6 +314,7 @@ async def cv_bank_upload( file_name=original, created_by=current_user.get("id"), pdf_bytes=content, + linkedin_url=parsed_linkedin, ) try: uploaded=S3().upload_for_record( @@ -332,6 +339,7 @@ async def cv_bank_upload( "file_name":row.file_name, "file_path":row.file_path or None, "candidate_email":row.candidate_email or None, + "linkedin_url":row.linkedin_url or None, "created_at":row.created_at.isoformat() if row.created_at else None, },"status_code":200}) except HTTPException: @@ -359,6 +367,7 @@ async def cv_bank_fetch( "file_path":(r.file_path or "").strip() or None, "candidate_email":r.candidate_email or None, "candidate_name":r.candidate_name or None, + "linkedin_url":r.linkedin_url or None, "created_at":r.created_at.isoformat() if r.created_at else None, } for r in rows] return JSONResponse(content={"data":data,"total":total,"status_code":200}) @@ -423,6 +432,61 @@ async def cv_bank_delete( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/candidate/matching/fetch") +async def matching_fetch( + top: int = Query(10, ge=1, le=500), + skip: int = Query(0, ge=0), + assigned: bool | None = Query(default=None), + search: str | None = Query(default=None), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + """Job Matching queue: CV Import 'No job' rows (apply_via=cv_bank).""" + try: + service=CandidateView(session=session) + data,total=await service.list_matching( + assigned=assigned,search=search,limit=top,offset=skip, + ) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/candidate/matching/fetch_by_id") +async def matching_fetch_by_id( + id: str = Query(...), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=CandidateView(session=session) + data=await service.get_matching(id) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/candidate/matching/assign") +async def matching_assign( + payload: MatchingAssign, + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), + session: AsyncSession = Depends(get_session), +): + """Set job_post_id on a CV-bank row — it then joins like any manual upload.""" + try: + service=CandidateView(session=session) + data=await service.assign_matching(payload.id,payload.job_post_id,current_user.get("id")) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.post("/candidate/inbox-match") async def candidate_inbox_match( inbox_message_id: str = Query(...), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index b599971..120e07b 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -380,25 +380,27 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): return out # ---- CV bank ----------------------------------------------------------- - # apply_via="cv_bank" rows are a private store of CVs with NO job, NO user - # account and NO inbox entry — deliberately invisible to Candidates, - # Pipeline (whose list inner-joins Users/JobPosts) and the Inbox. They wait - # until a recruiter picks them up; email is captured only when the CV - # contains one. + # apply_via="cv_bank" marks origin: the CV Import "No job" tab. Unassigned + # rows (job_post_id IS NULL) are the bank; Job Matching assigns a job_post_id + # (and a user account) so get_all's Users/JobPosts inner joins pick them up + # as normal applications. apply_via stays "cv_bank" so Matching can still + # list them. No inbox entry. @classmethod async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email, candidate_name, full_text, file_name, created_by, pdf_bytes, - content_type="application/pdf"): + content_type="application/pdf", + linkedin_url=None): """Bank a CV: metadata row + its bytes (cv_bank_files) in one commit. file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload). - When the CV carries an email, the candidate ACCOUNT is created/reused - (same pattern as create_manual_upload_candidate) so the person shows - up on the Candidates screen; unlike an application there is still no - inbox entry, no scoring, and no setup email. A CV with no detectable - email banks fine and simply stays account-less.""" + linkedin_url is the employment-agent extraction (None when the CV has + none — never a constructed slug). When the CV carries an email, the + candidate ACCOUNT is created/reused so the person shows up on the + Candidates screen; unlike an application there is still no inbox entry, + no scoring, and no setup email. A CV with no detectable email banks + fine and simply stays account-less.""" import os from role.models import EnumRoles, Roles @@ -427,12 +429,21 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): user.is_active = True session.add(user) + url = (linkedin_url or "").strip() or None + if url: + linkedin_slug = slug_from_url(url) or NO_SLUG + else: + linkedin_slug = primary_slug_from_text(full_text or "") + if user and url: + await Users.set_linkedin_url_if_empty(session, user_id=user.id, url=url) + row = cls( candidate_email=email, candidate_name=(candidate_name or "").strip() or (email or ""), job_post_id=None, full_text=full_text or "", - linkedin_slug=primary_slug_from_text(full_text or ""), + linkedin_slug=linkedin_slug, + linkedin_url=url, apply_via="cv_bank", user_id=user.id if user else None, created_by=cls._as_uuid(created_by), @@ -455,14 +466,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): @classmethod async def list_bank(cls, session: AsyncSession, limit=100, offset=0): + """Unassigned No-job CVs only — assigned rows leave the bank for Matching.""" + bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None)) total = ( await session.execute( - select(func.count()).select_from(cls).where(cls.apply_via == "cv_bank") + select(func.count()).select_from(cls).where(*bank) ) ).scalar() or 0 result = await session.execute( select(cls) - .where(cls.apply_via == "cv_bank") + .where(*bank) .order_by(cls.created_at.desc(), cls.id.desc()) .limit(limit) .offset(offset) @@ -470,12 +483,104 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): return list(result.scalars().all()), total @classmethod - async def delete_bank_cv(cls, session: AsyncSession, record_id): - """Hard delete, bank rows only — never reachable for application rows. - The cv_bank_files row goes with it via ON DELETE CASCADE.""" + async def list_matching(cls, session: AsyncSession, *, assigned=None, + search=None, limit=100, offset=0): + """No-job-tab origin (`apply_via=cv_bank`). `assigned` is tri-valued: + None = all, False = still in the bank, True = job_post_id set.""" + filters = [cls.apply_via == "cv_bank"] + if assigned is True: + filters.append(cls.job_post_id.is_not(None)) + elif assigned is False: + filters.append(cls.job_post_id.is_(None)) + if search and str(search).strip(): + like = f"%{str(search).strip()}%" + filters.append(or_( + cls.candidate_name.ilike(like), + cls.candidate_email.ilike(like), + cls.file_name.ilike(like), + )) + total = ( + await session.execute( + select(func.count()).select_from(cls).where(*filters) + ) + ).scalar() or 0 + result = await session.execute( + select(cls) + .where(*filters) + .order_by(cls.created_at.desc(), cls.id.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()), total + + @classmethod + async def _ensure_bank_user(cls, session: AsyncSession, row): + """Candidate USER so get_all / Candidates can see the row after assign. + insert_user commits; caller must reload `row` afterwards.""" + if row.user_id: + return None + import os + + from role.models import EnumRoles, Roles + from users.models import Users + from users.plugins import hash_password + + email = (row.candidate_email or "").strip().lower() + if not email: + email = f"cvbank-{row.id.hex}@no-email.local" + user = await Users.get_user_by_email(session, email) + if not user: + role = await Roles.get_role_by_name(session, EnumRoles.CANDIDATE.value) + name = (row.candidate_name or "").strip() or (row.file_name or "").strip() or email + user = await Users.insert_user(session, { + "name": name, + "email": email, + "role_id": role.id if role else 8, + "password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")), + "is_active": True, + "is_approved": True, + "is_deleted": False, + }) + elif user.is_deleted or not user.is_active: + user.is_deleted = False + user.is_active = True + session.add(user) + return user + + @classmethod + async def assign_job_post(cls, session: AsyncSession, record_id, job_post_id): + """Set job_post_id on a CV-bank row. None unassigns (back to the bank). + Origin apply_via stays cv_bank. Creates/reuses a candidate user so the + row joins like any other manual_upload_candidate application.""" row = await cls.get_by_id(session, record_id) if not row or row.apply_via != "cv_bank": return None + jid = cls._as_uuid(job_post_id) if job_post_id not in (None, "") else None + user = await cls._ensure_bank_user(session, row) + row = await cls.get_by_id(session, record_id) + if not row: + return None + if user is not None: + row.user_id = user.id + if not (row.candidate_email or "").strip() and user.email: + row.candidate_email = user.email + if not (row.candidate_name or "").strip() and user.name: + row.candidate_name = user.name + row.job_post_id = jid + row.status = "PENDING" if jid else "BANKED" + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def delete_bank_cv(cls, session: AsyncSession, record_id): + """Hard delete unassigned bank rows only — assigned rows are applications. + The cv_bank_files row goes with it via ON DELETE CASCADE.""" + row = await cls.get_by_id(session, record_id) + if not row or row.apply_via != "cv_bank" or row.job_post_id is not None: + return None file_row = await CvBankFiles.get(session, row.id) if file_row: await session.delete(file_row) diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index b84cb03..2c47e40 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -41,6 +41,28 @@ def serialize_candidate(row) -> dict: } +def serialize_matching_candidate(row, job_post=None) -> Dict[str,Any]: + """CV-bank origin row for Job Matching. assigned_job_post_id is job_posts.id.""" + name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown" + job_payload=serialize_job_post(job_post) if job_post else None + return { + "id":str(row.id), + "name":name, + "email":(row.candidate_email or "").strip() or None, + "file_name":(row.file_name or "").strip() or None, + "file_path":(row.file_path or "").strip() or None, + "resume_text":row.full_text or None, + "linkedin_url":row.linkedin_url or None, + "apply_via":row.apply_via, + "status":row.status or None, + "user_id":str(row.user_id) if row.user_id else None, + "assigned_job_post_id":str(row.job_post_id) if row.job_post_id else None, + "assigned_job_post":job_payload, + "created_at":row.created_at.isoformat() if row.created_at else None, + "updated_at":row.updated_at.isoformat() if row.updated_at else None, + } + + def serialize_manual_upload_candidate(row) -> Dict[str,Any]: return { "id":str(row.id) if row.id else None, diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index db4031b..6fdddd5 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -26,7 +26,7 @@ from job.candidate.plugins import ( get_scoring_settings, normalize_spaced_text, ) -from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate +from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate from job.job_post.models import JobPosts from job.job_post.serializers import serialize_job_post from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE @@ -46,7 +46,7 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv( async def parse_linkedin_url_from_cv(resume_text) -> str | None: - """Employment-agent `linkedin_url` key from parsed CV text. None if absent or the call fails.""" + """Employment-agent `linkedin_url` from CV text. None if absent, sentinel, invented, or the call fails.""" text=(resume_text or "").strip() if not text: return None @@ -1088,3 +1088,52 @@ class CandidateView: raise HTTPException(status_code=404,detail="Not found") name=(entry.get("name") or path.name).strip() or path.name return path,name + + async def list_matching(self,assigned=None,search=None,limit=10,offset=0): + rows,total=await Manual_UPLOAD_CANDIDATE.list_matching( + self.session,assigned=assigned,search=search,limit=limit,offset=offset, + ) + job_ids=[str(r.job_post_id) for r in rows if r.job_post_id] + posts=await JobPosts.get_by_ids(self.session,job_ids,active_only=False) if job_ids else [] + by_id={str(p.id):p for p in posts} + data=[ + serialize_matching_candidate(r,by_id.get(str(r.job_post_id)) if r.job_post_id else None) + for r in rows + ] + return data,total + + async def get_matching(self,record_id): + row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id) + if not row or row.apply_via!="cv_bank": + raise HTTPException(status_code=404,detail="CV not found") + job_post=None + if row.job_post_id: + job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id)) + return serialize_matching_candidate(row,job_post) + + async def assign_matching(self,record_id,job_post_id,current_user=None): + row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id) + if not row or row.apply_via!="cv_bank": + raise HTTPException(status_code=404,detail="CV not found") + job_post=None + if job_post_id not in (None,""): + job_post=await JobPosts.get_job_post_by_id(self.session,str(job_post_id)) + if not job_post or job_post.is_deleted: + raise HTTPException(status_code=404,detail="Job post not found") + was_unassigned=row.job_post_id is None + row=await Manual_UPLOAD_CANDIDATE.assign_job_post(self.session,record_id,job_post_id) + if not row: + raise HTTPException(status_code=404,detail="CV not found") + if job_post is None and row.job_post_id: + job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id)) + if was_unassigned and row.job_post_id and row.user_id: + title=(job_post.title if job_post else "") or str(row.job_post_id) + await HistoryRecorder(self.session).record( + HistoryEvent.CANDIDATE_CREATED.value, + actor_id=current_user,user_id=row.user_id, + manual_upload_candidate_id=row.id, + entity_type="manual_upload_candidate",entity_id=row.id, + to_value=title, + description="cv_bank",commit=True, + ) + return serialize_matching_candidate(row,job_post) diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 52b223f..92c6e61 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -2,6 +2,8 @@ def serialize_job_post(row) -> dict: return { "id": str(row.id), "title": row.title, + # Talent Pool / candidate filters key off attached job_posts.department. + "department": row.department or None, "employment_type": row.employment_type, "location": row.location, "experience_min": row.experience_min, @@ -31,8 +33,8 @@ def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict: """Requisition view of a job post, for the Jobs screen. Deliberately separate from serialize_job_post: that payload is shared by the - inbox, candidate and matching paths, and widening it would change five - response shapes at once. + inbox, candidate and matching paths. department is the one shared field — + talent-pool filters key off it on attached job_posts. """ return { "id": str(row.id), diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index bb997cb..86f0df2 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -92,6 +92,34 @@ export function viewCvBankCv(id) { }) } +/** + * Job Matching queue — CV Import "No job" rows (`apply_via=cv_bank`). + * Needs candidates.view. `assigned` is tri-valued: omit for all, false for + * still in the bank, true for rows that already have a job_post_id. + */ +export function listMatching({ search, top = 10, skip = 0, assigned } = {}) { + return request('/candidate/matching/fetch', { + params: { search, top, skip, assigned }, + }) +} + +/** One matching row by manual_upload_candidate id. Needs candidates.view. */ +export function getMatching(id) { + return request('/candidate/matching/fetch_by_id', { params: { id } }) +} + +/** + * Link (or unlink) a job post on a CV-bank row. Needs candidates.edit. + * After assign the row has job_post_id + user_id and Pipeline/Candidates + * fetch it like any other manual_upload_candidate. + */ +export function assignMatchingJob(id, jobPostId) { + return request('/candidate/matching/assign', { + method: 'POST', + body: { id, job_post_id: jobPostId }, + }) +} + /** * Score the decoded attachments of inbox messages against a job post. * Needs candidates.create. messageIds are inbox_messages PK uuids (the `id` diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index e2e81e0..315fd46 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -18,7 +18,7 @@ export const ROUTES = [ // --- Workspace --- { path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' }, { path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' }, - { path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'inbox.view', badge: 'matching' }, + { path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' }, { path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' }, { path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' }, { path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' }, diff --git a/frontend/src/app/useShell.js b/frontend/src/app/useShell.js index cbcc6be..ba517d5 100644 --- a/frontend/src/app/useShell.js +++ b/frontend/src/app/useShell.js @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { qk } from '../lib/queryKeys' import * as inboxApi from '../api/inbox' +import * as candidatesApi from '../api/candidates' import * as tasksApi from '../api/tasks' import * as jobsApi from '../api/jobs' import * as notificationsApi from '../api/notifications' @@ -94,15 +95,19 @@ export function useHotkeys({ onEscape }) { * that every mutating call site had to remember to call; these are derived, so * completing a task updates the badge with no call site involved at all. * - * `matching` is the unassigned applications queue — the one number Job Matching - * exists to drive to zero. + * `matching` is the unassigned CV-bank queue — CVs imported with No job that + * still have no job_post_id. Job Matching exists to drive that number to zero. */ export function useBadges() { const { data: matchingTotal = 0 } = useQuery({ - queryKey: qk.mailbox.assignments({ assigned: false }), + queryKey: qk.candidates.matching({ assigned: false, count: true }), queryFn: async () => { - const res = await inboxApi.listApplications({ assigned: false, top: 1 }) - return res?.total ?? 0 + try { + const res = await candidatesApi.listMatching({ assigned: false, top: 1 }) + return res?.total ?? 0 + } catch { + return 0 + } }, }) const { data: tasksTotal = 0 } = useQuery({ diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 641f3eb..a8907f1 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -92,6 +92,8 @@ export const qk = { count: (p = {}) => ['candidates', 'count', p], detail: (id) => ['candidates', 'detail', id], history: (id, p = {}) => ['candidates', 'history', id, p], + matching: (p = {}) => ['candidates', 'matching', p], + matchingDetail: (id) => ['candidates', 'matching', 'detail', id], }, // Board rows come from the same endpoint as qk.candidates.list but are cached // MAPPED (kanban cards, not the raw envelope), so they need their own key — diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index 07c9699..c0772f3 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -349,9 +349,8 @@ export default function CvImport() { ) } -/* The stored-CV bank — a private store with no job, account or inbox entry. - This list is the bank's home: browse, download, or remove; picking a CV up - for a job later is a future action. */ + /* The stored-CV bank — CVs imported with No job. Unassigned rows live here; + assigning a job in Job Matching sets job_post_id and they leave this list. */ function CvBank() { const { toast } = useToast() const qc = useQueryClient() @@ -432,6 +431,11 @@ function CvBank() { {r.candidate_email || 'No email detected'} {r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''} + {r.linkedin_url ? ( +
+ {r.linkedin_url} +
+ ) : null} {r.file_path ? (
{r.file_path} diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index 54a877d..70b3a5f 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -1,18 +1,16 @@ /* ============================================================ - Job Matching — assign each inbound application to exactly one job post. + Job Matching — assign a job post to CVs stored with "No job" + (CV Import → CV bank, apply_via=cv_bank on manual_upload_candidate). - Queue layout mirrors Inbox (Tabs over a .split). Page size defaults to 10; - skip = (page-1)*limit, same as Inbox. The AI's suggested_job_post_ids land - here as a radiogroup; Assign writes assigned_job_post_id. Nothing is - marked read — that stays Inbox's job so this page cannot silently move the - inbox nav badge. + Needs assignment: job_post_id is null. Assign writes job_post_id (and a + candidate user) so Pipeline / Candidates fetch the row like any other + manual upload. Assigned tab is the same origin with a job already linked. ============================================================ */ import { useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import OpenResumeButton from '../ui/OpenResumeButton' import PageHeader from '../ui/PageHeader' import { Tabs } from '../ui/Tabs' @@ -23,34 +21,26 @@ import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' -import * as inboxApi from '../api/inbox' +import * as candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' import * as s3Api from '../api/s3' -import { - avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta, -} from '../data/seed' +import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed' const TABS = [ { key: 'needs', label: 'Needs assignment' }, { key: 'assigned', label: 'Assigned' }, - { key: 'none', label: 'No suggestions' }, { key: 'all', label: 'All' }, ] const TAB_FILTERS = { needs: { assigned: false }, assigned: { assigned: true }, - none: { assigned: false, noSuggestions: true }, all: {}, } -/** Same cap as GET /inbox/all-applications `top`. */ const PAGE_SIZE_MAX = 500 -const RESUME_STATUS = { - processing: 'Parsing', matched: 'Parsed', no_text: 'Failed', - failed: 'Failed', dlq: 'Failed', skipped: 'Pending', -} +const CV_BANK_META = { icon: 'file', color: 'var(--c2)', channel: 'Upload' } function parseDate(value) { if (!value) return null @@ -58,13 +48,39 @@ function parseDate(value) { return Number.isNaN(d.getTime()) ? null : d } -function sourceFrom(messageTo) { - const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim() - if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null } - const flat = raw.toLowerCase().replace(/[^a-z]/g, '') - const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, ''))) - if (hit) return { source: hit, sourceMeta: sourceMeta[hit] } - return { source: raw.split(',')[0].trim(), sourceMeta: null } +function mapRow(row) { + const name = row.name || row.email || row.file_name || 'Unknown' + return { + id: String(row.id), + name, + initials: initialsOf(name), + color: avatarColor(name), + email: row.email || '', + position: row.file_name || 'CV bank', + source: 'CV bank', + sourceMeta: CV_BANK_META, + received: parseDate(row.created_at), + resumeText: row.resume_text || '', + filePath: row.file_path || '', + assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, + assignedPost: row.assigned_job_post || null, + status: row.status || null, + userId: row.user_id || null, + linkedinUrl: row.linkedin_url || null, + } +} + +async function fetchQueue(params) { + const res = await candidatesApi.listMatching(params) + const rows = Array.isArray(res?.data) ? res.data : [] + return { rows: rows.map(mapRow), total: res?.total ?? rows.length } +} + +async function fetchDetail(recordId) { + const res = await candidatesApi.getMatching(recordId) + const row = res?.data + if (!row) return null + return mapRow(row) } function SourceChip({ item }) { @@ -76,106 +92,18 @@ function SourceChip({ item }) { ) } -function htmlToText(value) { - const raw = (value || '').trim() - if (!raw) return '' - if (!/<[a-z!/]/i.test(raw)) return raw - const withBreaks = raw - .replace(//gi, '\n') - .replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n') - const doc = new DOMParser().parseFromString(withBreaks, 'text/html') - doc.querySelectorAll('script, style, head').forEach((n) => n.remove()) - return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() -} - -function mapApplication(row) { - const name = row.name || row.email || 'Unknown' - return { - id: String(row.id), - name, - initials: initialsOf(name), - color: avatarColor(name), - email: row.email || '', - position: row.position || '(no subject)', - ...sourceFrom(row.source), - received: parseDate(row.received), - unread: Boolean(row.unread), - processing: row.processing || 'Unread', - resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending', - resumeText: row.resume_text || '', - filePath: row.file_path || '', - suggestedIds: Array.isArray(row.suggested_job_post_ids) - ? row.suggested_job_post_ids.map(String) - : [], - assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, - matchStatus: row.match_status || null, - matchSummary: row.match_summary || '', - matchReasoning: row.match_reasoning || '', - matchError: row.match_error || '', - matchedAt: parseDate(row.matched_at), - } -} - -async function fetchApplications(params) { - const res = await inboxApi.listApplications(params) - const rows = Array.isArray(res?.data) ? res.data : [] - return { rows: rows.map(mapApplication), total: res?.total ?? rows.length } -} - -async function fetchDetail(recordId) { - const res = await inboxApi.getMessage(recordId) - const row = res?.data - if (!row) return null - const name = row.sender_name || row.fromEmail || 'Unknown' - return { - id: String(row.id), - name, - initials: initialsOf(name), - color: avatarColor(name), - email: row.fromEmail || '', - position: row.subject || '(no subject)', - // Same value as `position`, kept under its own name: the email panel renders - // it as a mail header, not as the candidate's role. - subject: row.subject || '', - ...sourceFrom(row.message_to), - body: htmlToText(row.body), - // Kept raw for the HTML viewer; `body` stays as the plain-text fallback for - // mail that never had markup. EmailBody sanitises before rendering. - bodyHtml: row.body || '', - resumeText: row.resume_text || '', - files: Array.isArray(row.files) ? row.files : [], - filePath: row.file_path || '', - resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending', - processing: row.unread ? 'Unread' : 'Read', - suggestedIds: (row.suggested_job_post_ids || []).map(String), - suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [], - assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, - assignedPost: row.assigned_job_post || null, - matchStatus: row.match_status || null, - matchSummary: row.match_summary || '', - matchReasoning: row.match_reasoning || '', - matchError: row.match_error || '', - matchedAt: parseDate(row.matched_at), - } -} - function AssignmentBadge({ item, titleById }) { - if (item.matchStatus === 'processing') { - return Matching… - } if (item.assignedId) { - const title = titleById.get(item.assignedId) || 'Assigned' + const title = titleById.get(item.assignedId) || item.assignedPost?.title || 'Assigned' return {title} } - const n = item.suggestedIds.length - if (n > 0) return {n} suggested - return No match + return No job } export default function Matching() { const { toast } = useToast() const { can } = useAuth() - const canEdit = can('inbox.edit') + const canEdit = can('candidates.edit') const qc = useQueryClient() const [searchParams, setSearchParams] = useSearchParams() const deepLink = searchParams.get('record') @@ -188,7 +116,6 @@ export default function Matching() { const [selection, setSelection] = useState(null) const [manualPost, setManualPost] = useState(null) const [showPicker, setShowPicker] = useState(false) - const [whyOpen, setWhyOpen] = useState(false) const tabFilter = TAB_FILTERS[tab] ?? {} const listParams = useMemo(() => ({ @@ -199,25 +126,21 @@ export default function Matching() { }), [tabFilter, page, pageSize, q]) const listQuery = useQuery({ - queryKey: qk.mailbox.assignments({ ...listParams, tab }), - queryFn: () => fetchApplications(listParams), + queryKey: qk.candidates.matching({ ...listParams, tab }), + queryFn: () => fetchQueue(listParams), }) const needsCount = useQuery({ - queryKey: qk.mailbox.assignments({ assigned: false, count: true }), - queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0, + queryKey: qk.candidates.matching({ assigned: false, count: true }), + queryFn: async () => (await candidatesApi.listMatching({ assigned: false, top: 1 }))?.total ?? 0, }) const assignedCount = useQuery({ - queryKey: qk.mailbox.assignments({ assigned: true, count: true }), - queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0, + queryKey: qk.candidates.matching({ assigned: true, count: true }), + queryFn: async () => (await candidatesApi.listMatching({ assigned: true, top: 1 }))?.total ?? 0, }) const allCount = useQuery({ - queryKey: qk.mailbox.assignments({ count: true }), - queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0, - }) - const noneCountQuery = useQuery({ - queryKey: qk.mailbox.assignments({ kind: 'none', count: true }), - queryFn: async () => (await inboxApi.listApplications({ assigned: false, noSuggestions: true, top: 1 }))?.total ?? 0, + queryKey: qk.candidates.matching({ count: true }), + queryFn: async () => (await candidatesApi.listMatching({ top: 1 }))?.total ?? 0, }) const rows = listQuery.data?.rows ?? [] @@ -226,13 +149,10 @@ export default function Matching() { const pages = Math.max(1, Math.ceil(total / pageSize)) const currentPage = Math.min(page, pages) - const noneCount = noneCountQuery.data ?? 0 - useEffect(() => { if (listQuery.isSuccess && page > pages) setPage(pages) }, [listQuery.isSuccess, page, pages]) - // Preselect deep link once, then clear the query so refresh doesn't re-pin. useEffect(() => { if (!deepLink) return undefined setSelectedId(deepLink) @@ -241,7 +161,7 @@ export default function Matching() { }, [deepLink, setSearchParams]) const detailQuery = useQuery({ - queryKey: qk.mailbox.message(selectedId), + queryKey: qk.candidates.matchingDetail(selectedId), queryFn: () => fetchDetail(selectedId), enabled: Boolean(selectedId), }) @@ -249,12 +169,10 @@ export default function Matching() { const detail = detailQuery.data const listRow = filtered.find((r) => r.id === selectedId) || rows.find((r) => r.id === selectedId) - // Hydrate titles for list badges (assigned + suggestions) in one call. const hydrateIds = useMemo(() => { const ids = new Set() for (const r of rows) { if (r.assignedId) ids.add(r.assignedId) - for (const id of r.suggestedIds) ids.add(id) } return [...ids] }, [rows]) @@ -275,24 +193,11 @@ export default function Matching() { return map }, [titlesQuery.data]) - // Reset local selection when the selected application changes. useEffect(() => { setManualPost(null) - setWhyOpen(false) if (detail?.assignedId) setSelection(detail.assignedId) - else if (detail?.suggestedIds?.[0]) setSelection(detail.suggestedIds[0]) else setSelection(null) - }, [detail?.id, detail?.assignedId, detail?.suggestedIds]) - - const suggestionCards = useMemo(() => { - const fromDetail = detail?.suggestedPosts || [] - const byId = new Map(fromDetail.map((p) => [String(p.id), p])) - const ids = detail?.suggestedIds || listRow?.suggestedIds || [] - return ids.map((id, i) => ({ - rank: i + 1, - post: byId.get(id) || { id, unavailable: true }, - })) - }, [detail, listRow]) + }, [detail?.id, detail?.assignedId]) const selectedPost = useMemo(() => { if (!selection) return null @@ -300,17 +205,11 @@ export default function Matching() { if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) { return detail.assignedPost } - const hit = suggestionCards.find((c) => String(c.post.id) === String(selection)) - return hit?.post || null - }, [selection, manualPost, detail, suggestionCards]) + return null + }, [selection, manualPost, detail]) const assignMutation = useMutation({ - mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId), - onMutate: async ({ recordId, jobPostId }) => { - await qc.cancelQueries({ queryKey: qk.mailbox.all() }) - await qc.cancelQueries({ queryKey: ['mailbox', 'assignments'] }) - return { recordId, jobPostId } - }, + mutationFn: ({ recordId, jobPostId }) => candidatesApi.assignMatchingJob(recordId, jobPostId), onError: (err) => { toast(friendlyAuthError(err, 'Could not assign job post.'), 'error') }, @@ -321,12 +220,11 @@ export default function Matching() { else toast(`${name} unassigned`, 'success') }, onSettled: async (_res, _err, vars) => { - await qc.invalidateQueries({ queryKey: qk.mailbox.all() }) - await qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] }) - await qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) }) await qc.invalidateQueries({ queryKey: qk.candidates.all() }) + await qc.invalidateQueries({ queryKey: qk.cvBank.all() }) + await qc.invalidateQueries({ queryKey: qk.pipeline.all() }) + await qc.invalidateQueries({ queryKey: qk.candidates.matchingDetail(vars.recordId) }) - // Auto-advance only on the Needs assignment tab after a real assign. if (tab === 'needs' && vars.jobPostId) { const idx = filtered.findIndex((r) => r.id === vars.recordId) const next = filtered[idx + 1] || filtered[idx - 1] || null @@ -335,17 +233,6 @@ export default function Matching() { }, }) - const rematchMutation = useMutation({ - mutationFn: (recordId) => inboxApi.rematch(recordId), - onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'), - onSuccess: () => toast('Match re-queued', 'success'), - onSettled: (_r, _e, recordId) => { - qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) }) - qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] }) - }, - }) - - // Keyboard: j/k move queue, 1–5 pick suggestion, Enter assigns, Esc clears. useEffect(() => { const onKey = (e) => { const tag = e.target?.tagName @@ -360,9 +247,6 @@ export default function Matching() { const idx = filtered.findIndex((r) => r.id === selectedId) const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))] if (next) setSelectedId(next.id) - } else if (e.key >= '1' && e.key <= '5') { - const card = suggestionCards[Number(e.key) - 1] - if (card && !card.post.unavailable) setSelection(String(card.post.id)) } else if (e.key === 'Enter' && canEdit && selection && selection !== detail?.assignedId) { e.preventDefault() assignMutation.mutate({ recordId: selectedId, jobPostId: selection }) @@ -373,27 +257,25 @@ export default function Matching() { } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) - }, [filtered, selectedId, suggestionCards, canEdit, selection, detail, assignMutation]) + }, [filtered, selectedId, canEdit, selection, detail, assignMutation]) const counts = { needs: needsCount.data ?? 0, assigned: assignedCount.data ?? 0, - none: noneCount, all: allCount.data ?? 0, } - const resumeText = detail?.resumeText || listRow?.resumeText || '' const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow) - const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus) + const resumeText = detail?.resumeText || listRow?.resumeText || '' return (
- + {!canEdit && (
- Your account does not hold inbox.edit, which the server requires to - assign, unassign, or retry a match. Controls below stay disabled. + Your account does not hold candidates.edit, which the server requires to + assign or unassign a role. Controls below stay disabled.
)} @@ -425,7 +307,7 @@ export default function Matching() {
{listQuery.isPending && ( - Fetching applications. + Fetching CVs from the bank. )} {listQuery.isError && ( @@ -435,14 +317,14 @@ export default function Matching() { {listQuery.isSuccess && filtered.length === 0 && ( {tab === 'needs' - ? 'Every application in this view has a role.' + ? 'Every CV from the No job tab has a role, or the bank is empty.' : 'Nothing matches this filter.'} )} {filtered.map((i) => (
setSelectedId(i.id)} > @@ -480,13 +362,13 @@ export default function Matching() {
{!selectedId ? (
- - Choose an item from the list to review suggestions and assign a role. + + Choose an item from the list to assign a job post.
) : detailQuery.isError ? (
- + {friendlyAuthError(detailQuery.error, 'Request failed')}
@@ -497,15 +379,9 @@ export default function Matching() { loading={detailQuery.isPending} canEdit={canEdit} selection={selection} - setSelection={setSelection} - manualPost={manualPost} - suggestionCards={suggestionCards} selectedPost={selectedPost} resumeText={resumeText} resumeKey={resumeKey} - whyOpen={whyOpen} - setWhyOpen={setWhyOpen} - matchFailed={matchFailed} onPickManual={() => setShowPicker(true)} onSkip={() => { const idx = filtered.findIndex((r) => r.id === selectedId) @@ -521,9 +397,7 @@ export default function Matching() { assignMutation.mutate({ recordId: selectedId, jobPostId: null }) }} onChange={() => setShowPicker(true)} - onRematch={() => rematchMutation.mutate(selectedId)} assigning={assignMutation.isPending} - rematching={rematchMutation.isPending} /> )}
@@ -549,23 +423,15 @@ function MatchingWorkspace({ loading, canEdit, selection, - setSelection, - manualPost, - suggestionCards, selectedPost, resumeText, resumeKey, - whyOpen, - setWhyOpen, - matchFailed, onPickManual, onSkip, onAssign, onUnassign, onChange, - onRematch, assigning, - rematching, }) { const i = { name: detail?.name || listRow?.name || '…', @@ -574,8 +440,9 @@ function MatchingWorkspace({ position: detail?.position || listRow?.position, source: detail?.source || listRow?.source, sourceMeta: detail?.sourceMeta || listRow?.sourceMeta, - processing: detail?.processing || listRow?.processing, - resumeStatus: detail?.resumeStatus || listRow?.resumeStatus, + email: detail?.email || listRow?.email || '', + received: detail?.received || listRow?.received, + linkedinUrl: detail?.linkedinUrl || listRow?.linkedinUrl || null, } const assigned = detail?.assignedPost @@ -588,20 +455,28 @@ function MatchingWorkspace({
{i.name}
-
{i.position}
+
{i.email || i.position}
{' '} - - {i.resumeStatus} - + {i.received && Added {fmtDate(i.received)}} {loading && Loading details…}
- {s3Api.canOpen(resumeKey) && ( + {(s3Api.canOpen(resumeKey) || i.linkedinUrl) && (
- + {s3Api.canOpen(resumeKey) && } + {i.linkedinUrl && ( + + LinkedIn + + )}
)} @@ -626,10 +501,10 @@ function MatchingWorkspace({
- -
@@ -646,86 +521,29 @@ function MatchingWorkspace({ }} >
- {matchFailed ? ( -
-
{detail?.matchError || 'Matching failed for this application.'}
- -
- ) : ( -
-
AI verdict
-

- {detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'} -

- {detail?.matchedAt && ( -
Matched {fmtDate(detail.matchedAt)}
- )} - {(detail?.matchReasoning || listRow?.matchReasoning) && ( - - )} - {whyOpen && ( -

- {detail?.matchReasoning || listRow?.matchReasoning} -

- )} -
- )} - - {/* Email first: it is the application itself, and the resume is its - attachment. Reading order follows that. */} - {(detail?.subject || detail?.body) && ( -
-
Email
-
Subject: {detail.subject || '(no subject)'}
- {looksLikeHtml(detail.bodyHtml) ? ( - - ) : ( -
-                  {detail.body || 'No email body.'}
-                
- )} -
- )} -
CV
{s3Api.canOpen(resumeKey) ? ( ) : ( -

No CV file stored in S3 for this application.

+

No CV file stored in S3 for this record.

)} + {resumeText ? ( +
+              {resumeText}
+            
+ ) : null}
-
-
Suggested roles
- {suggestionCards.length === 0 && !manualPost ? ( - -

No job post was suggested. Choose a role manually.

+
+
Job post
+ {!selectedPost ? ( + +

Pick a role for this CV. After assign it is a normal candidate on that job.

-
) : ( - suggestionCards.map(({ rank, post }) => ( - setSelection(id)} - resumeText={resumeText} - /> - )) - )} - {manualPost && ( setSelection(id)} + selected={String(selection) === String(selectedPost.id)} + onSelect={() => {}} resumeText={resumeText} /> )} @@ -758,10 +564,10 @@ function MatchingWorkspace({ className="btn btn-secondary" style={{ width: '100%', marginTop: 8 }} disabled={!canEdit} - title={!canEdit ? 'Requires inbox.edit' : undefined} + title={!canEdit ? 'Requires candidates.edit' : undefined} onClick={onPickManual} > - Choose a different role… + {selectedPost ? 'Choose a different role…' : 'Choose a role…'}
@@ -780,11 +586,10 @@ function MatchingWorkspace({ diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index 0e6520f..b602f8c 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -4,12 +4,11 @@ The layout, the toolbar, the card and the 8-tab profile modal are the originals, unchanged. Only the data source moved. - The endpoint returns name, email, experience, application_status and the - suggested job title. It has no aiScore, skills, currentCompany, source or - department — the agent writes a verdict and prose, not a score, and no - résumé-derived skills are persisted. Each record is therefore OVERLAID on a - seed candidate: real values win, seed fills the rest, so the card renders - exactly as it always did. + The endpoint returns name, email, experience, application_status, suggested + job titles and the attached job_posts (with department). It has no skills or + currentCompany on list rows — those still come from the seed overlay. Each + record is therefore OVERLAID on a seed candidate: real values win, seed fills + the rest, so the card renders exactly as it always did. Clicking a card opens CandidateProfile in place. It used to deep-link into /candidates, which stopped resolving once the ids became real user_ids. @@ -69,6 +68,25 @@ function years(value) { return Number.isFinite(n) ? n : null } +/** + * Distinct departments from the candidate's assigned + suggested job posts. + * Seed templates also carry a department, but that is a prototype leftover and + * must not drive the toolbar filter — it would never match /job/departments/fetch. + */ +function departmentsOf(row) { + const seen = new Set() + const out = [] + const add = (value) => { + const d = typeof value === 'string' ? value : '' + if (!d || seen.has(d)) return + seen.add(d) + out.push(d) + } + add(row.assigned_job_post?.department) + for (const jp of row.job_posts || []) add(jp.department) + return out +} + /** * One API record overlaid on one seed candidate. * @@ -84,6 +102,7 @@ function merge(row, template) { || row.current_title const stage = STAGE_FROM_STATUS[row.application_status] || template.stage const experience = years(row.experience) + const departments = departmentsOf(row) return { ...template, @@ -97,6 +116,10 @@ function merge(row, template) { status: stage, currentTitle: title || template.currentTitle, jobTitle: title || template.jobTitle, + // Live job-post departments only. Seed department is left on `department` + // for the seed-only profile modal, but the filter reads `departments`. + departments, + department: departments[0] || template.department, // Prefer real Form / platform tags from manual_upload; seed only as fallback. source: row.source || template.source, // NO seed fallback. `ai_score` is the candidate's current ats_results row, @@ -185,7 +208,7 @@ export default function TalentPool() { const list = useMemo( () => pool.filter((c) => { - if (dept && c.department !== dept) return false + if (dept && !(c.departments || []).includes(dept)) return false if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false return true }), @@ -235,7 +258,7 @@ export default function TalentPool() {