Merge pull request 'Link_ATS_with_Sys' (#13) from Link_ATS_with_Sys into main
Reviewed-on: #13pull/14/head
commit
0568dcefb7
|
|
@ -6,6 +6,7 @@ Icon
|
|||
?
|
||||
._*
|
||||
dist/**
|
||||
dist/**/*
|
||||
# Editor / IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -32,14 +32,14 @@ def clamp_company_to_resume(func):
|
|||
|
||||
@wraps(func)
|
||||
def wrapper(data,resume_text="",*args,**kwargs):
|
||||
company,education=func(data,resume_text,*args,**kwargs)
|
||||
company,education,current_title=func(data,resume_text,*args,**kwargs)
|
||||
company=(company or "").strip()
|
||||
if not company or company.lower()==NO_COMPANY.lower():
|
||||
return NO_COMPANY,education
|
||||
return NO_COMPANY,education,current_title
|
||||
haystack=(resume_text or "").lower()
|
||||
if company.lower() not in haystack:
|
||||
return NO_COMPANY,education
|
||||
return company,education
|
||||
return NO_COMPANY,education,current_title
|
||||
return company,education,current_title
|
||||
|
||||
return wrapper
|
||||
|
||||
|
|
@ -49,14 +49,14 @@ def clamp_education_to_resume(func):
|
|||
|
||||
@wraps(func)
|
||||
def wrapper(data,resume_text="",*args,**kwargs):
|
||||
company,education=func(data,resume_text,*args,**kwargs)
|
||||
company,education,current_title=func(data,resume_text,*args,**kwargs)
|
||||
education=(education or "").strip()
|
||||
if not education or education.lower()==EDUCATION.lower():
|
||||
return company,EDUCATION
|
||||
return company,EDUCATION,current_title
|
||||
haystack=(resume_text or "").lower()
|
||||
if education.lower() not in haystack:
|
||||
return company,EDUCATION
|
||||
return company,education
|
||||
return company,EDUCATION,current_title
|
||||
return company,education,current_title
|
||||
|
||||
return wrapper
|
||||
|
||||
|
|
@ -68,8 +68,11 @@ def parse_employment_response(data,resume_text:str="") -> tuple[str,str]:
|
|||
"""Pull company + education from LLM JSON; decorators clamp to the resume."""
|
||||
current=data.get("current_employment")
|
||||
education=data.get("education")
|
||||
current_title=data.get("current_title")
|
||||
if not isinstance(current,str):
|
||||
current=""
|
||||
if not isinstance(education,str):
|
||||
education=""
|
||||
return current.strip(),education.strip()
|
||||
if not isinstance(current_title,str):
|
||||
current_title=""
|
||||
return current.strip(),education.strip(),current_title.strip()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
|
||||
from employment_agent.decorators import parse_employment_response
|
||||
from employment_agent.prompt import EDUCATION,NO_COMPANY,prompt,user_prompt
|
||||
from employment_agent.prompt import CURRENT_TITLE,EDUCATION,NO_COMPANY,prompt,user_prompt
|
||||
from llm_setup import llm_call
|
||||
|
||||
logger=logging.getLogger("employment_agent")
|
||||
|
|
@ -18,7 +18,7 @@ logger=logging.getLogger("employment_agent")
|
|||
async def run_employment_agent(*,resume_text="") -> tuple[str,str]:
|
||||
text=(resume_text or "").strip()
|
||||
if not text:
|
||||
return NO_COMPANY,EDUCATION
|
||||
return NO_COMPANY,EDUCATION,CURRENT_TITLE
|
||||
try:
|
||||
data=await llm_call(prompt(),user_prompt(text),json_mode=True)
|
||||
return parse_employment_response(data,text)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import json
|
|||
|
||||
NO_COMPANY="no company was mentioned"
|
||||
EDUCATION="No Education Mentioned"
|
||||
|
||||
CURRENT_TITLE="No JOB POSITION MENTIONED"
|
||||
|
||||
def prompt():
|
||||
return f"""You are an HR-ATS recruiting assistant.
|
||||
|
|
@ -20,15 +20,19 @@ name and their education (degree / school) when present.
|
|||
Rules:
|
||||
- Return only the company name that appears in the resume text for the ongoing / most recent role.
|
||||
- Return only education that appears in the resume text.
|
||||
- Return only job title that appears in the resume text.
|
||||
- The company string you return MUST appear verbatim (or as a clear substring) in the resume text.
|
||||
- The education string you return MUST appear verbatim (or as a clear substring) in the resume text.
|
||||
- The job title string you return MUST appear verbatim (or as a clear substring) in the resume text.
|
||||
- Do not invent a company. If none is mentioned, return exactly: {NO_COMPANY}
|
||||
- Do not invent education. If none is mentioned, return exactly: {EDUCATION}
|
||||
- Do not invent job title. If none is mentioned, return exactly: {CURRENT_TITLE}
|
||||
|
||||
Respond with JSON only:
|
||||
{{
|
||||
"current_employment": "Company Name",
|
||||
"education": "Degree / School"
|
||||
"education": "Degree / School",
|
||||
"current_title": "Job Title"
|
||||
}}
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx")
|
||||
candidate_education: str | None = Field(default=None)
|
||||
current_employment: str | None = Field(default=None)
|
||||
current_title: str | None = Field(default=None)
|
||||
# Denormalised dashboard / list-screen fields. server_default is load-bearing
|
||||
# for every NOT NULL column — these arrive as ALTERs on a populated table.
|
||||
ats_score: float | None = Field(default=None)
|
||||
|
|
@ -279,6 +280,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
candidate_education=None,
|
||||
candidate_phone_number=None,
|
||||
current_employment=None,
|
||||
current_title=None,
|
||||
suggested_job_post_ids=None,
|
||||
summary="",
|
||||
reasoning="",
|
||||
|
|
@ -297,6 +299,8 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
row.candidate_education = candidate_education
|
||||
if current_employment is not None:
|
||||
row.current_employment = current_employment
|
||||
if current_title is not None:
|
||||
row.current_title = current_title
|
||||
row.suggested_job_post_ids = suggested_job_post_ids
|
||||
row.match_summary = summary or None
|
||||
row.match_reasoning = reasoning or None
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ def serialize_application(message: Inbox_Messages) -> dict:
|
|||
"phone": message.candidate_phone_number,
|
||||
"experience": message.experience or "",
|
||||
"current_employment": message.current_employment or "",
|
||||
"current_title": message.current_title or "",
|
||||
"recruiter": str(message.recruiter_id) if message.recruiter_id else None,
|
||||
"duplicate": message.is_duplicate,
|
||||
"processing_state": message.processing_state,
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
|||
if status=="failed":
|
||||
raise RuntimeError(result.get("error") or "agent returned failed status")
|
||||
|
||||
current_employment,education=await run_employment_agent(resume_text=text)
|
||||
current_employment,education,current_title=await run_employment_agent(resume_text=text)
|
||||
|
||||
async with session_scope() as session:
|
||||
await Inbox_Messages.set_match_result(
|
||||
|
|
@ -129,6 +129,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
|||
experience=result.get("experience") or "",
|
||||
candidate_phone_number=phone,
|
||||
current_employment=current_employment,
|
||||
current_title=current_title,
|
||||
candidate_education=education,
|
||||
suggested_job_post_ids=result.get("suggested_job_post_ids") or [],
|
||||
summary=result.get("summary") or "",
|
||||
|
|
@ -158,5 +159,6 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
|||
"status":status,
|
||||
"suggested_job_post_ids":result.get("suggested_job_post_ids") or [],
|
||||
"current_employment":current_employment,
|
||||
"current_title":current_title,
|
||||
"education":education,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ 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 users.views import User
|
||||
from job.job_post.plugins import PlatformAlias
|
||||
from fastapi import UploadFile, File, Form
|
||||
from dotenv import load_dotenv
|
||||
|
|
@ -134,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),
|
||||
|
|
@ -158,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,
|
||||
|
|
@ -177,6 +180,21 @@ async def create_manual_candidate(
|
|||
FileRead.discard_upload(saved_path)
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@router.get("/candidate/fetch/users")
|
||||
async def fetch_users(
|
||||
role_id:int=Query(8),
|
||||
top:int=Query(10),
|
||||
skip:int=Query(0),
|
||||
search:str=Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=User(session=session)
|
||||
data=await service.get_users(role_id=role_id,top=top,skip=skip)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@router.post("/candidate/cv_upload")
|
||||
async def cv_upload(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -78,6 +80,7 @@ def serialize_candidate_profile(
|
|||
"application_status": message.application_status if message else None,
|
||||
"experience": message.experience if message else None,
|
||||
"current_employment": message.current_employment if message else None,
|
||||
"current_title": message.current_title if message else None,
|
||||
"resume_text": message.resume_text if message else None,
|
||||
"suggested_job_post_ids": list(message.suggested_job_post_ids or []) if message else [],
|
||||
"assigned_job_post_id": str(message.assigned_job_post_id) if message and message.assigned_job_post_id else None,
|
||||
|
|
@ -97,6 +100,7 @@ def serialize_candidate_profile(
|
|||
"phone": message.candidate_phone_number if message else None,
|
||||
"education": message.candidate_education if message else None,
|
||||
"currentCompany": message.current_employment if message else None,
|
||||
"current_title": message.current_title if message else None,
|
||||
"stage": message.application_status if message else None,
|
||||
"source": source_from_message_to(message.message_to if message else None),
|
||||
"applied": message.message_received_time if message else None,
|
||||
|
|
@ -116,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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from optparse import Option
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING,List,Optional
|
||||
|
|
@ -88,7 +89,11 @@ class Users(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
async def get_users(
|
||||
cls, session: AsyncSession, top: int | None, skip: int, search: str | None
|
||||
cls, session: AsyncSession,
|
||||
top: Optional[int]=None,
|
||||
skip: Optional[int]=None,
|
||||
search: Optional[str]=None,
|
||||
role_id:Optional[int]=None
|
||||
):
|
||||
statement = (
|
||||
select(cls)
|
||||
|
|
@ -102,6 +107,8 @@ class Users(SQLModel, table=True):
|
|||
statement = statement.offset(skip)
|
||||
if top is not None:
|
||||
statement = statement.limit(top)
|
||||
if role_id:
|
||||
statement = statement.where(cls.role_id == role_id)
|
||||
result = await session.execute(statement)
|
||||
return result.scalars().all()
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from dotenv import load_dotenv
|
|||
load_dotenv()
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import jwt
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class User:
|
||||
|
|
@ -60,8 +61,11 @@ class User:
|
|||
await service.send_confirmation(user)
|
||||
return user
|
||||
|
||||
async def get_users(self,top,skip,search=None):
|
||||
users=await Users.get_users(self.session,top,skip,search)
|
||||
async def get_users(self,top:Optional[int]=None,skip:Optional[int]=None,search:Optional[str]=None,role_id:Optional[int]=None):
|
||||
if role_id:
|
||||
users=await Users.get_users(self.session,top=top,skip=skip,search=search,role_id=role_id)
|
||||
else:
|
||||
users=await Users.get_users(self.session,top=top,skip=skip,search=search)
|
||||
return [serialize_user(u) for u in users]
|
||||
|
||||
async def get_user_by_id(self,record_id):
|
||||
|
|
|
|||
|
|
@ -87,6 +87,64 @@ export function toCandidateView(row) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate USER accounts — `users` rows filtered by role, not the scored
|
||||
* `candidates` table. Needs candidates.view.
|
||||
*
|
||||
* role_id 8 is the seeded `candidate` role (backend/role/models.py::EnumRoles);
|
||||
* the route defaults to it, and we send it explicitly so a re-seed that renumbers
|
||||
* the roles fails loudly here rather than silently listing the wrong people.
|
||||
*
|
||||
* Three things this route does NOT do, all verified against
|
||||
* backend/job/app.py::fetch_users:
|
||||
* - it returns `{data, status_code}` with NO `total`, so a caller cannot show a
|
||||
* row count or drive server-side pagination from the response alone;
|
||||
* - `top` defaults to 10, so omitting it silently truncates to ten rows;
|
||||
* - it accepts a `search` query param but never forwards it to the service
|
||||
* layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op
|
||||
* server-side. Filtering stays client-side until that is fixed.
|
||||
*/
|
||||
export function listCandidateUsers({ roleId = 8, top = 500, skip = 0 } = {}) {
|
||||
return request('/candidate/fetch/users', {
|
||||
params: { role_id: roleId, top, skip },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `users` row -> the row shape the Candidates table renders.
|
||||
*
|
||||
* A user account carries identity only. Everything the ATS produces
|
||||
* (score, matched skills, critique, the job it was scored against) lives in the
|
||||
* `candidates` table keyed by inbox_message_id, with no user_id to join on, so
|
||||
* those fields are null here by construction rather than by omission.
|
||||
*/
|
||||
export function toCandidateUserView(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.id,
|
||||
name: row.name || row.email || 'Unknown',
|
||||
email: row.email ?? null,
|
||||
isActive: row.is_active ?? null,
|
||||
roleName: row.role_name ?? null,
|
||||
applied: row.created_at ? new Date(row.created_at) : null,
|
||||
// No ATS data on a users row — see the note above.
|
||||
jobId: null,
|
||||
filename: null,
|
||||
source: null,
|
||||
currentTitle: null,
|
||||
currentCompany: null,
|
||||
experience: null,
|
||||
aiScore: null,
|
||||
matchedSkills: [],
|
||||
missingSkills: [],
|
||||
critique: null,
|
||||
scoringStatus: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
inboxMessageId: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate profiles — the `inbox -> users -> roles` join, restricted server-side
|
||||
* to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).
|
||||
|
|
@ -161,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)
|
||||
|
|
@ -176,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)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Pagination, useDataTable } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import CandidateProfile from './ScoredCandidateProfile'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
|
|
@ -23,16 +23,28 @@ import * as candidatesApi from '../api/candidates'
|
|||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import { useFormState } from '../components/AuthLayout'
|
||||
import { persist } from '../data/seedQueries'
|
||||
import { atsRecommendationClass, avatarColor, initials as initialsOf, sources, stages } from '../data/seed'
|
||||
import { avatarColor, initials as initialsOf, sources, stages } from '../data/seed'
|
||||
|
||||
const ATS_BANDS = ['85+', '70-84', '<70']
|
||||
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
|
||||
const EMPTY_FILTERS = { job: '', skill: '', source: '', ats: '', status: '' }
|
||||
const EMPTY_FILTERS = { account: '' }
|
||||
|
||||
/** The seeded `candidate` role (backend/role/models.py::EnumRoles). */
|
||||
const CANDIDATE_ROLE_ID = 8
|
||||
|
||||
/* Rows are candidate USER accounts (GET /candidate/fetch/users?role_id=8), not
|
||||
rows of the scored `candidates` table.
|
||||
|
||||
Why: /candidate/scored/fetch only ever returns CVs that have been through the
|
||||
ATS, so the pool was empty for every candidate who has an account but no score
|
||||
yet. The user list is the real population; the score is an attribute some of
|
||||
them have.
|
||||
|
||||
The consequence is that the ATS columns have no source on this screen — see
|
||||
toCandidateUserView. Open a candidate to get their score, which
|
||||
ScoredCandidateProfile still reads from the scored endpoint. */
|
||||
async function fetchCandidates() {
|
||||
const res = await candidatesApi.listCandidates()
|
||||
const res = await candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(candidatesApi.toCandidateView)
|
||||
return rows.map(candidatesApi.toCandidateUserView)
|
||||
}
|
||||
|
||||
async function fetchJobs() {
|
||||
|
|
@ -97,7 +109,7 @@ export default function Candidates() {
|
|||
const [q, setQ] = useState('')
|
||||
const [filters, setFilters] = useState(EMPTY_FILTERS)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [sortMode, setSortMode] = useState('relevance')
|
||||
const [sortMode, setSortMode] = useState('recent')
|
||||
const [profileFor, setProfileFor] = useState(null)
|
||||
const [atsFor, setAtsFor] = useState(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
|
@ -107,15 +119,8 @@ export default function Candidates() {
|
|||
[jobsById],
|
||||
)
|
||||
|
||||
/** ATS score + matched-skill ratio + recency — same shape as before, but every
|
||||
input is now real: matched/missing come from the model, applied from the DB. */
|
||||
const relevance = useCallback((c) => {
|
||||
if (c.aiScore == null) return 0
|
||||
const total = c.matchedSkills.length + c.missingSkills.length
|
||||
const skillRatio = total ? c.matchedSkills.length / total : 0.5
|
||||
const recency = c.applied ? 1 - Math.min(1, (Date.now() - c.applied) / (90 * 864e5)) : 0.5
|
||||
return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10)
|
||||
}, [])
|
||||
/* The relevance blend (score + matched-skill ratio + recency) went with the
|
||||
scoring columns — none of its three inputs exists on a users row. */
|
||||
|
||||
const openProfile = useCallback(
|
||||
(c) => {
|
||||
|
|
@ -140,58 +145,43 @@ export default function Candidates() {
|
|||
}
|
||||
}, [location.state, candidates, openProfile])
|
||||
|
||||
const skillOptions = useMemo(() => {
|
||||
const set = new Set()
|
||||
for (const c of candidates) for (const s of c.matchedSkills) set.add(s)
|
||||
return [...set].sort((a, b) => a.localeCompare(b)).slice(0, 40)
|
||||
}, [candidates])
|
||||
|
||||
const jobOptions = useMemo(
|
||||
() => (jobsQuery.data ?? []).map((j) => j.title),
|
||||
[jobsQuery.data],
|
||||
)
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const f = filters
|
||||
let list = candidates.filter((c) => {
|
||||
if (f.job && jobTitleOf(c) !== f.job) return false
|
||||
if (f.skill && !c.matchedSkills.includes(f.skill)) return false
|
||||
if (f.source && c.source !== f.source) return false
|
||||
if (f.status === 'Scored' && c.scoringStatus !== 'completed') return false
|
||||
if (f.status === 'Failed' && c.scoringStatus !== 'failed') return false
|
||||
if (f.ats === '85+' && (c.aiScore == null || c.aiScore < 85)) return false
|
||||
if (f.ats === '70-84' && (c.aiScore == null || c.aiScore < 70 || c.aiScore > 84)) return false
|
||||
if (f.ats === '<70' && (c.aiScore == null || c.aiScore >= 70)) return false
|
||||
if (f.account === 'Active' && !c.isActive) return false
|
||||
if (f.account === 'Unconfirmed' && c.isActive) return false
|
||||
if (q) {
|
||||
// Client-side: the route accepts `search` but never forwards it to the
|
||||
// service layer, so asking the server to filter would be a silent no-op.
|
||||
const term = q.toLowerCase()
|
||||
const hay = [
|
||||
c.name, c.filename, c.currentTitle ?? '', c.currentCompany ?? '',
|
||||
c.matchedSkills.join(' '),
|
||||
c.name, c.email ?? '', c.filename ?? '', c.currentTitle ?? '',
|
||||
c.currentCompany ?? '', c.matchedSkills.join(' '),
|
||||
].join(' ').toLowerCase()
|
||||
if (!hay.includes(term)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (sortMode === 'relevance') list = [...list].sort((a, b) => relevance(b) - relevance(a))
|
||||
else if (sortMode === 'ats') list = [...list].sort((a, b) => (b.aiScore ?? -1) - (a.aiScore ?? -1))
|
||||
else if (sortMode === 'recent') list = [...list].sort((a, b) => (b.applied ?? 0) - (a.applied ?? 0))
|
||||
if (sortMode === 'recent') list = [...list].sort((a, b) => (b.applied ?? 0) - (a.applied ?? 0))
|
||||
else if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
|
||||
return list
|
||||
}, [candidates, filters, q, sortMode, relevance, jobTitleOf])
|
||||
}, [candidates, filters, q, sortMode])
|
||||
|
||||
/* Columns follow the row source. A `users` row carries identity only, so the
|
||||
four scoring columns (Scored For / Exp / Relevance / ATS) have nothing to
|
||||
read and are gone rather than rendered as permanent em-dashes — the same
|
||||
rule the Inbox screen set and this file's header states. They come back the
|
||||
moment the rows carry a score again. */
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ key: 'name', label: 'Candidate', sortable: true },
|
||||
{ key: '_job', label: 'Scored For', sortable: true, sortValue: jobTitleOf },
|
||||
{ key: 'experience', label: 'Exp', sortable: true, align: 'center' },
|
||||
{ key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: relevance },
|
||||
{ key: 'scoringStatus', label: 'Status', sortable: true },
|
||||
{ key: 'aiScore', label: 'ATS', sortable: true, align: 'center' },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'isActive', label: 'Account', sortable: true },
|
||||
{ key: 'applied', label: 'Added', sortable: true },
|
||||
{ key: '_a', label: 'Actions', align: 'right' },
|
||||
],
|
||||
[relevance, jobTitleOf],
|
||||
[],
|
||||
)
|
||||
|
||||
const t = useDataTable({ columns, rows, pageSize: 10 })
|
||||
|
|
@ -217,7 +207,7 @@ export default function Candidates() {
|
|||
<div>
|
||||
<h1 className="page-title">Candidates</h1>
|
||||
<p className="page-sub">
|
||||
{rows.length} candidate{rows.length === 1 ? '' : 's'} · ranked by AI relevance
|
||||
{rows.length} candidate account{rows.length === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
|
|
@ -257,8 +247,6 @@ export default function Candidates() {
|
|||
<div className="spacer" />
|
||||
<label className="text-muted text-sm">Sort:</label>
|
||||
<select className="select" value={sortMode} onChange={(e) => setSortMode(e.target.value)}>
|
||||
<option value="relevance">AI Relevance</option>
|
||||
<option value="ats">ATS Score</option>
|
||||
<option value="recent">Most Recent</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
</select>
|
||||
|
|
@ -269,11 +257,10 @@ export default function Candidates() {
|
|||
className="filter-panel"
|
||||
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
|
||||
>
|
||||
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobOptions} />
|
||||
<Facet label="Matched Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillOptions} />
|
||||
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={['upload', 'inbox']} labels={SOURCE_LABEL} />
|
||||
<Facet label="ATS Score" value={filters.ats} onChange={(v) => setFilter('ats', v)} any="Any Score" options={ATS_BANDS} />
|
||||
<Facet label="Status" value={filters.status} onChange={(v) => setFilter('status', v)} any="Any Status" options={['Scored', 'Failed']} />
|
||||
{/* Job / Matched Skill / Source / ATS Score / scoring Status are gone
|
||||
with the scoring columns: on a users row every one of them would
|
||||
match nothing and silently empty the table. */}
|
||||
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -336,38 +323,17 @@ export default function Candidates() {
|
|||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} />
|
||||
<div>
|
||||
<div className="cell-primary">{c.name}</div>
|
||||
<div className="cell-sub">
|
||||
{c.currentTitle ?? c.filename}
|
||||
{c.currentCompany ? ` · ${c.currentCompany}` : ''}
|
||||
</div>
|
||||
<div className="cell-sub">{c.roleName ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm">{jobTitleOf(c)}</div>
|
||||
<div className="cell-sub">{SOURCE_LABEL[c.source] ?? c.source}</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{c.experience != null ? <><b>{c.experience}</b>y</> : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{c.scoringStatus === 'completed' ? (
|
||||
<span className={`badge ${atsRecommendationClass(recommendationOf(c))} badge-plain`}>
|
||||
{relevance(c)}%
|
||||
</span>
|
||||
) : '—'}
|
||||
<span className="text-sm">{c.email ?? '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
{c.scoringStatus === 'completed'
|
||||
? <Badge className="b-green">Scored</Badge>
|
||||
: <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{c.aiScore != null ? (
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => openAts(c)}>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
</span>
|
||||
) : '—'}
|
||||
{c.isActive
|
||||
? <Badge className="b-green">Active</Badge>
|
||||
: <Badge className="b-amber">Unconfirmed</Badge>}
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm">
|
||||
|
|
@ -376,7 +342,6 @@ export default function Candidates() {
|
|||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="row-actions">
|
||||
<button className="act-btn" data-tip="ATS Match" onClick={() => openAts(c)}><Icon name="target" /></button>
|
||||
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -546,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: '',
|
||||
})
|
||||
|
||||
|
|
@ -608,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,
|
||||
|
|
@ -664,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