Hiring manager

pull/29/head
ahmed.mujtaba 2026-08-28 15:54:21 +05:00
parent c03a640c96
commit b5d2b05a6d
32 changed files with 668 additions and 222 deletions

View File

@ -42,3 +42,5 @@ tools/
*.log
tmp/
temp/
tests/**
/backend/tests/**

View File

@ -4,16 +4,20 @@ Pure module: no FastAPI imports, no HTTPException, and no module-level state.
Mirrors job/candidate/decorators.py stacked wrappers that clean LLM output
before the task persists it:
raw JSON -> require_json_object -> clamp_company_to_resume
-> clamp_education_to_resume -> clamp_linkedin_url
-> parse_employment_response
parse_employment_response -> clamp_phone -> prefer_extracted_phone
-> clamp_linkedin_url -> clamp_education_to_resume
-> clamp_company_to_resume
Generic factories (`clamp_field`, `clamp_in_resume`) bind a field name; the
assigned aliases below are what call sites stack.
"""
from __future__ import annotations
import re
from functools import wraps
from employment_agent.prompt import EDUCATION,NO_COMPANY,NO_LINKEDIN
from employment_agent.prompt import EDUCATION,NO_COMPANY,NO_LINKEDIN,NO_PHONE
def require_json_object(func):
@ -28,81 +32,96 @@ def require_json_object(func):
return wrapper
def clamp_company_to_resume(func):
"""Keep company only when it appears in resume_text; else NO_COMPANY."""
def clamp_field(key,clean):
"""Run `clean(value, resume_text)` on one dict key; leave the rest alone."""
@wraps(func)
def wrapper(data,resume_text="",*args,**kwargs):
company,education,current_title,linkedin_url=func(data,resume_text,*args,**kwargs)
company=(company or "").strip()
if not company or company.lower()==NO_COMPANY.lower():
return NO_COMPANY,education,current_title,linkedin_url
def decorator(func):
@wraps(func)
def wrapper(data,resume_text="",*args,**kwargs):
fields=func(data,resume_text,*args,**kwargs)
fields[key]=clean(fields.get(key),resume_text)
return fields
return wrapper
return decorator
def clamp_in_resume(key,sentinel):
"""Keep the field only when it appears in resume_text; else `sentinel`."""
def clean(value,resume_text):
text=(value or "").strip()
if not text or text.lower()==sentinel.lower():
return sentinel
haystack=(resume_text or "").lower()
if company.lower() not in haystack:
return NO_COMPANY,education,current_title,linkedin_url
return company,education,current_title,linkedin_url
return wrapper
if text.lower() not in haystack:
return sentinel
return text
return clamp_field(key,clean)
def clamp_education_to_resume(func):
"""Keep education only when it appears in resume_text; else EDUCATION."""
def _clean_linkedin(value,resume_text):
url=(value or "").strip()
if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"):
return None
lowered=url.lower()
if "linkedin.com/company/" in lowered:
return None
if "linkedin.com" not in lowered and "lnkd.in" not in lowered:
return None
if not lowered.startswith("http://") and not lowered.startswith("https://"):
url="https://"+url.lstrip("/")
return url
def _clean_phone(value,resume_text):
text=(value or "").strip()
if not text or text.lower() in (NO_PHONE.lower(),"none","null","n/a","-"):
return None
digits=re.sub(r"\D","",text)
if digits.startswith("00"):
digits=digits[2:]
if len(digits)<10 or len(digits)>15:
return None
if (resume_text or "").strip():
haystack=re.sub(r"\D","",resume_text)
if digits not in haystack:
return None
return text
def prefer_extracted_phone(func):
"""Merge CV regex phone with the LLM value; keep the longer complete number."""
@wraps(func)
def wrapper(data,resume_text="",*args,**kwargs):
company,education,current_title,linkedin_url=func(data,resume_text,*args,**kwargs)
education=(education or "").strip()
if not education or education.lower()==EDUCATION.lower():
return company,EDUCATION,current_title,linkedin_url
haystack=(resume_text or "").lower()
if education.lower() not in haystack:
return company,EDUCATION,current_title,linkedin_url
return company,education,current_title,linkedin_url
fields=func(data,resume_text,*args,**kwargs)
from employment_agent.plugins import prefer_full_phone,scan_phone
fields["phone"]=prefer_full_phone(fields.get("phone"),scan_phone(resume_text))
return fields
return wrapper
def clamp_linkedin_url(func):
"""Keep linkedin_url only when the model returned a LinkedIn profile URL.
This is output validation, not CV scanning: the URL is the agent's own
`linkedin_url` key. Company pages and non-LinkedIn URLs are dropped.
"""
@wraps(func)
def wrapper(data,resume_text="",*args,**kwargs):
company,education,current_title,linkedin_url=func(data,resume_text,*args,**kwargs)
url=(linkedin_url or "").strip()
if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"):
return company,education,current_title,None
lowered=url.lower()
if "linkedin.com/company/" in lowered:
return company,education,current_title,None
if "linkedin.com" not in lowered and "lnkd.in" not in lowered:
return company,education,current_title,None
if not lowered.startswith("http://") and not lowered.startswith("https://"):
url="https://"+url.lstrip("/")
return company,education,current_title,url
return wrapper
clamp_company_to_resume=clamp_in_resume("current_employment",NO_COMPANY)
clamp_education_to_resume=clamp_in_resume("education",EDUCATION)
clamp_linkedin_url=clamp_field("linkedin_url",_clean_linkedin)
clamp_phone=clamp_field("phone",_clean_phone)
@require_json_object
@clamp_company_to_resume
@clamp_education_to_resume
@clamp_linkedin_url
def parse_employment_response(data,resume_text:str="") -> tuple[str,str,str,str|None]:
"""Pull company, education, title, and linkedin_url from the agent JSON."""
current=data.get("current_employment")
education=data.get("education")
current_title=data.get("current_title")
linkedin_url=data.get("linkedin_url")
if not isinstance(current,str):
current=""
if not isinstance(education,str):
education=""
if not isinstance(current_title,str):
current_title=""
if not isinstance(linkedin_url,str):
linkedin_url=""
return current.strip(),education.strip(),current_title.strip(),linkedin_url.strip()
@prefer_extracted_phone
@clamp_phone
def parse_employment_response(data,resume_text=""):
"""Pull company, education, title, linkedin_url, and phone from the agent JSON."""
def as_str(key):
value=data.get(key)
return value.strip() if isinstance(value,str) else ""
return {
"current_employment":as_str("current_employment"),
"education":as_str("education"),
"current_title":as_str("current_title"),
"linkedin_url":as_str("linkedin_url"),
"phone":as_str("phone"),
}

View File

@ -15,10 +15,16 @@ from llm_setup import llm_call
logger=logging.getLogger("employment_agent")
async def run_employment_agent(*,resume_text="") -> tuple[str,str,str,str|None]:
async def run_employment_agent(*,resume_text=""):
text=(resume_text or "").strip()
if not text:
return NO_COMPANY,EDUCATION,CURRENT_TITLE,None
return {
"current_employment":NO_COMPANY,
"education":EDUCATION,
"current_title":CURRENT_TITLE,
"linkedin_url":None,
"phone":None,
}
try:
data=await llm_call(prompt(),user_prompt(text),json_mode=True)
return parse_employment_response(data,text)

View File

@ -0,0 +1,107 @@
"""CV contact parsers — phone and LinkedIn, decorated by employment_agent.decorators.
Pure module: no FastAPI imports and no HTTPException.
Call like the rest of the backend:
fields=parse_phone({"phone":raw},resume_text)
phone=fields["phone"]
fields=parse_linkedin({"linkedin_url":raw},resume_text)
url=fields["linkedin_url"]
`scan_phone` is the regex guts `prefer_extracted_phone` uses so the stacked
parser cannot recurse into itself.
"""
from __future__ import annotations
import re
from employment_agent.decorators import (
clamp_linkedin_url,
clamp_phone,
prefer_extracted_phone,
)
_PK_MOBILE=re.compile(
r"(?:(?:\+|00)[\s\-.]*)?(?:92[\s\-.]*)?0?3\d{2}(?:[\s\-.\n]*\d){7}"
)
_PHONE_SPAN=re.compile(
r"(?:(?:\+|00)[\s\-.]*)?(?:\(?\d[\s\-()./\n]*){8,16}\d"
)
def _phone_digits(raw:str) -> str:
digits=re.sub(r"\D","",raw or "")
if digits.startswith("00"):
digits=digits[2:]
return digits
def _phone_score(digits:str) -> int:
"""Prefer complete PK mobiles; reject CNIC-shaped 13-digit runs."""
n=len(digits)
if n<10 or n>15:
return -1
if n==13 and not digits.startswith("92"):
return -1
if digits.startswith("03") and n==11:
return 200
if digits.startswith("923") and n==12:
return 190
if digits.startswith("3") and n==10:
return 180
return n
def scan_phone(text:str) -> str|None:
"""Regex scan of CV text — complete numbers only, never a truncated prefix."""
best=None
best_score=-1
haystack=text or ""
for pattern in (_PK_MOBILE,_PHONE_SPAN):
for match in pattern.finditer(haystack):
raw=re.sub(r"[\n\r]+"," ",match.group(0))
raw=re.sub(r"[\s\-()]+"," ",raw).strip()
score=_phone_score(_phone_digits(raw))
if score>best_score:
best_score=score
best=raw
if best_score>=180:
return best
return best
def prefer_full_phone(*candidates) -> str|None:
"""Keep the candidate with the most digits (min 10). Truncated regex loses."""
best=None
best_n=-1
for raw in candidates:
value=(raw or "").strip()
if not value:
continue
n=len(_phone_digits(value))
if n>=10 and n>best_n:
best_n=n
best=value
return best
def _as_str(data,key):
if not isinstance(data,dict):
return ""
value=data.get(key)
return value.strip() if isinstance(value,str) else ""
@prefer_extracted_phone
@clamp_phone
def parse_phone(data,resume_text=""):
"""Form/CV phone through clamp_phone + prefer_extracted_phone."""
return {"phone":_as_str(data,"phone")}
@clamp_linkedin_url
def parse_linkedin(data,resume_text=""):
"""Stored or pasted LinkedIn URL through clamp_linkedin_url."""
return {"linkedin_url":_as_str(data,"linkedin_url")}

View File

@ -11,14 +11,15 @@ NO_COMPANY="no company was mentioned"
EDUCATION="No Education Mentioned"
CURRENT_TITLE="No JOB POSITION MENTIONED"
NO_LINKEDIN="no linkedin url mentioned"
NO_PHONE="no phone number mentioned"
def prompt():
return f"""You are an HR-ATS recruiting assistant.
You are given CV/resume text. Identify the candidate's CURRENT employer company
name, their education (degree / school), their current job title, and their
LinkedIn profile URL when present.
name, their education (degree / school), their current job title, their
LinkedIn profile URL, and their phone number when present.
Rules:
- Return only the company name that appears in the resume text for the ongoing / most recent role.
@ -35,15 +36,53 @@ linkedin_url (its own key — extract this separately from the other fields):
- Return the candidate's own public LinkedIn profile URL (linkedin.com/in/..., /pub/..., /mwlite/in/..., or lnkd.in/...).
- Reconstruct the URL if PDF extraction wrapped or spaced it (e.g. "linkedin.com/in/\\njane-doe" or "linkedin . com / in / jane-doe").
- Clickable icon links may appear as bare URLs on their own lines at the end of the text; use those.
- 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}
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.
- Pakistani mobiles are 11 digits local (03XX-XXXXXXX / 03XX XXXXXXX) or +92 3XX XXXXXXX (12 digits with country code). Copy the last group in full never stop after 7 or 8 digits.
- If PDF extraction wrapped the number across lines (e.g. "0321-5551\\n234"), join the groups into one complete number.
- Spaces, hyphens, and parentheses are allowed; do not delete trailing digits to "clean" the value.
- Do not invent a number. If none is mentioned, return exactly: {NO_PHONE}
Examples of CORRECT values (copy this completeness; these are format samples, not this candidate):
Example 1 local 11-digit PK mobile, full LinkedIn:
Resume: "Ali Khan | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer"
JSON:
{{
"current_employment": "Acme",
"education": "BS CS",
"current_title": "Engineer",
"linkedin_url": "https://www.linkedin.com/in/ali-khan",
"phone": "0321-5551234"
}}
Example 2 +92 with spaces; every digit kept:
Resume: "Phone: +92 333 123 4567"
JSON phone must be "+92 333 123 4567" (12 digits after stripping separators: 923331234567). Not "+92 333 123" and not "+92 333 1234".
Example 3 PDF wrapped the last three digits onto the next line:
Resume: "Mobile: 0300-1234\\n567"
JSON phone must be "0300-1234567" (11 digits). Returning "0300-1234" (last three missing) is wrong.
Example 4 4-3-4 grouping:
Resume: "Cell: 0301 234 5678"
JSON phone must be "0301 234 5678". Not "0301 234".
Example 5 wrapped LinkedIn slug:
Resume: "linkedin.com/in/\\njane-doe-123"
JSON linkedin_url must be "https://www.linkedin.com/in/jane-doe-123". Not ".../jane-doe".
Respond with JSON only:
{{
"current_employment": "Company Name",
"education": "Degree / School",
"current_title": "Job Title",
"linkedin_url": "https://www.linkedin.com/in/slug"
"linkedin_url": "https://www.linkedin.com/in/slug",
"phone": "+92 300 1234567"
}}
"""

View File

@ -197,7 +197,8 @@ async def fetch_form_data(
processing_state: str | None = Query(None),
is_duplicate: bool | None = Query(None),
offset: int = Query(0,ge=0),
limit: int | None = Query(None,ge=1),
# Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged.
limit: int | None = Query(None,ge=1,le=500),
current_user: dict = Depends(_FORM_DATA_READ),
session: AsyncSession = Depends(get_session),
):
@ -230,6 +231,23 @@ async def fetch_form_data_counts(
raise HTTPException(status_code=500,detail=str(e))
@router.get("/sheet/form-data/count")
async def count_form_data(
sheet: str | None = Query(None),
current_user: dict = Depends(_FORM_DATA_READ),
session: AsyncSession = Depends(get_session),
):
"""Unfiltered form_data total for a sheet. Called once when Sheet Forms opens."""
try:
service=SheetFormData(session=session)
total=await service.count_rows(sheet=sheet)
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/sheet/form-data/{record_id}")
async def fetch_form_data_by_id(
record_id: str,

View File

@ -436,6 +436,7 @@ class SheetFormData(Sheet):
the CANDIDATE user it creates). platform='Form' is the source badge.
"""
session=self._require_session()
from employment_agent.plugins import parse_linkedin,parse_phone
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from job.history.views import HistoryRecorder
from job.history.enums import HistoryEvent
@ -467,16 +468,14 @@ class SheetFormData(Sheet):
if resume:
file_name=resume.rsplit("/",1)[-1][:180] or "resume"
# Sheet already stores LinkedIn on profile_link — copy it through, do not parse the CV.
profile=(form_row.profile_link or "").strip()
linkedin_url=None
if profile:
linkedin_url=profile if profile.lower().startswith("http") else f"https://{profile.lstrip('/')}"
linkedin_url=parse_linkedin({"linkedin_url":profile},"").get("linkedin_url")
phone_fields=parse_phone({"phone":(form_row.candidate_number or "").strip()},"")
row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session,{
"candidate_email":email,
"candidate_name":(form_row.name or "").strip() or email,
"candidate_phone":(form_row.candidate_number or "").strip(),
"candidate_phone":phone_fields.get("phone") or "",
"job_post_id":str(form_row.job_post_id),
"current_company":(form_row.current_company or "").strip(),
"current_position":(form_row.position_applied_for or "").strip(),
@ -514,6 +513,9 @@ class SheetFormData(Sheet):
async def get_counts(self,sheet=None):
return await FormData.count_processing(self._require_session(),sheet=sheet)
async def count_rows(self,sheet=None):
return await FormData.count_form_data(self._require_session(),sheet=sheet)
async def get_imported_sheets(self):
session=self._require_session()
sheets=await FormData.get_sheet_names(session)

View File

@ -145,7 +145,7 @@ async def fetch_email_sync(
async def fetch_inbox(
record_id: str | None = Query(None),
search: str | None = Query(None),
top: int | None = Query(None),
top: int | None = Query(None, ge=1, le=500),
skip: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
):
@ -283,7 +283,8 @@ async def get_all_applications(
assigned: bool | None = Query(default=None),
is_duplicate: bool | None = Query(default=None),
search: str | None = Query(None),
top: int | None = Query(None),
# Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged.
top: int | None = Query(None, ge=1, le=500),
skip: int = Query(0, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
session: AsyncSession = Depends(get_session),
@ -312,6 +313,22 @@ async def get_all_applications(
raise HTTPException(status_code=500,detail=str(e))
@router.get("/inbox/all-applications/count")
async def count_all_applications(
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Unfiltered application total. Called once when Inbox Email opens."""
try:
service=Email(session=session)
total=await service.count_inbox_messages()
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/inbox/counts")
async def get_inbox_counts(
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),

View File

@ -27,7 +27,7 @@ logger = logging.getLogger("inbox.models")
# Placeholder only. The account lands inactive and the candidate is mailed a
# confirmation link; the real password comes from the reset flow afterwards.
DEFAULT_CANDIDATE_PASSWORD = os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")
CANDIDATE_ROLE_ID_FALLBACK = 4 # mirrors users/views.py:signup_user
CANDIDATE_ROLE_ID = 8 # seeded candidate role (id 4 is hiring_manager, the signup default)
SKIP_SENDER_PREFIXES = ("noreply", "no-reply", "donotreply", "do-not-reply",
"mailer-daemon", "postmaster", "bounce")
@ -570,11 +570,10 @@ class Inbox_Messages(SQLModel, table=True):
)).scalar_one_or_none()
if user_id is None:
role=await Roles.get_role_by_name(session,EnumRoles.CANDIDATE.value)
user=Users(
name=cls._sender_display_name(email_data,address),
email=address,
role_id=role.id if role else CANDIDATE_ROLE_ID_FALLBACK,
role_id=CANDIDATE_ROLE_ID,
password=hash_password(DEFAULT_CANDIDATE_PASSWORD),
)
session.add(user)
@ -727,8 +726,9 @@ class Inbox_Messages(SQLModel, table=True):
async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None
):
# Page size is the caller's `top` (Inbox sends 10); `skip` is (page-1)*top
# so page 1 -> 0..9, page 2 -> 10..19. Newest first via created_at.
# Page size is the caller's `top` (Inbox sends 10/25/50/100); `skip` is
# (page-1)*top so page 1 of 25 -> 0..24, page 2 -> 25..49. Newest first
# via created_at.
statement = cls._apply_filters(
select(cls).order_by(cls.created_at.desc()),
search, isread, application_status, assigned, is_duplicate,

View File

@ -6,7 +6,6 @@ import asyncio
import base64
import logging
import os
import re
import uuid
from pathlib import Path
from urllib.parse import quote
@ -32,11 +31,6 @@ TEAMS_API_TOKEN=os.getenv("TEAMS_API_TOKEN")
MAIL_ACCEPTED_STATUS=202
_ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments"
# Prefer +92 / 03xx style numbers; fall back to a looser intl-ish pattern.
_PHONE=re.compile(
r"(?:\+?92[\s\-]?)?0?3\d{2}[\s\-]?\d{7}"
r"|(?:\+?\d{1,3}[\s\-]?)?(?:\(?\d{2,4}\)?[\s\-]?)?\d{3,4}[\s\-]?\d{3,4}"
)
async def request_email_confirmation(email):
@ -216,13 +210,6 @@ async def attach_email_pdfs_to_s3(session,row,pdfs,*,created_new:bool):
raise
def extract_phone(text:str) -> str|None:
m=_PHONE.search(text or "")
if not m:
return None
return re.sub(r"[\s\-()]+"," ",m.group(0)).strip()
async def extract_resume_text(file_paths:list[str]) -> tuple[str,str]:
"""Extract text from S3 URLs or leftover local PDF paths."""
refs=[p.strip() for p in (file_paths or []) if p and p.strip()]

View File

@ -12,7 +12,7 @@ from agent.execute_agent import run_agent
from db_setup import session_scope
from employment_agent.execute_agent import run_employment_agent
from inbox.models import Inbox_Messages,Inbox,AtsResults
from inbox.plugins import extract_phone,extract_resume_text
from inbox.plugins import extract_resume_text
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY,broker
@ -105,7 +105,6 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
job_posts=[serialize_job_post(p) for p in posts]
text,extract_err=await extract_resume_text(paths)
phone=extract_phone(text) if text else None
if not text:
async with session_scope() as session:
await Inbox_Messages.set_match_result(
@ -118,9 +117,14 @@ 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,current_title,linkedin_url=await run_employment_agent(
fields=await run_employment_agent(
resume_text=text if not body else f"{text}\n\n{body}",
)
current_employment=fields["current_employment"]
education=fields["education"]
current_title=fields["current_title"]
linkedin_url=fields["linkedin_url"]
phone=fields["phone"]
async with session_scope() as session:
await Inbox_Messages.set_match_result(

View File

@ -220,10 +220,10 @@ async def create_manual_candidate(
@router.get("/candidate/fetch/users")
async def fetch_users(
role_id:int=Query(4),
top:int=Query(10),
skip:int=Query(0),
search:str=Query(None),
role_id:Optional[int]=Query(None),
top:Optional[int]=Query(None),
skip:Optional[int]=Query(None),
search:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
@ -234,6 +234,22 @@ async def fetch_users(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch/users/count")
async def count_candidate_users(
role_id:Optional[int]=Query(None),
search:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Total matching users for the Candidates pager. Called once on page open."""
try:
service=User(session=session)
total=await service.count_users(search=search,role_id=role_id)
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/cv_upload")
async def cv_upload(
file: UploadFile = File(...),

View File

@ -188,7 +188,6 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
async def create_manual_upload_candidate(cls, session: AsyncSession, fields: dict):
import os
from role.models import EnumRoles, Roles
from users.models import Users
from users.plugins import hash_password
@ -206,11 +205,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
user=await Users.get_user_by_email(session,email)
if not user:
role=await Roles.get_role_by_name(session,EnumRoles.CANDIDATE.value)
user=await Users.insert_user(session,{
"name":name,
"email":email,
"role_id":role.id if role else 4,
"role_id":8,
"password":hash_password(default_pw),
"is_active":True,
"is_deleted":False,

View File

@ -35,6 +35,7 @@ from job.history.views import HistoryRecorder
from job.notes.serializers import serialize_note
from job.candidate.plugins import extract_candidate_email
from users.models import Users
from employment_agent.plugins import parse_phone
load_dotenv()
logger=logging.getLogger("job.candidate.views")
@ -51,8 +52,10 @@ async def parse_linkedin_url_from_cv(resume_text) -> str | None:
return None
try:
from employment_agent.execute_agent import run_employment_agent
*_,url=await run_employment_agent(resume_text=text)
return url
from employment_agent.plugins import parse_linkedin
fields=await run_employment_agent(resume_text=text)
url_fields=parse_linkedin({"linkedin_url":fields.get("linkedin_url") or ""},text)
return url_fields.get("linkedin_url")
except Exception:
logger.exception("employment agent linkedin_url parse failed")
return None
@ -604,11 +607,16 @@ class CandidateView:
raise HTTPException(status_code=422,detail="file is empty")
parsed_linkedin=await parse_linkedin_url_from_cv(full_text)
phone_fields=parse_phone(
{"phone":(candidate_phone or "").strip()},
full_text or "",
)
phone=phone_fields.get("phone") or ""
data={
"candidate_email":email,
"candidate_name":(candidate_name or "").strip(),
"candidate_phone":(candidate_phone or "").strip(),
"candidate_phone":phone,
"job_post_id":job_post_id,
"current_company":(current_company or "").strip(),
"current_position":(current_position or "").strip(),

View File

@ -278,6 +278,13 @@ class S3:
raw=(url or "").strip()
if not raw:
raise S3ServiceError("url is required",status_code=422)
# Query/proxy layers sometimes leave %3A/%2F (or a second %25 layer).
# Plain Email/... keys and already-decoded https:// URLs skip this.
if "%" in raw:
from urllib.parse import unquote
raw=unquote(raw)
if "%" in raw:
raw=unquote(raw)
if not self.is_http_url(raw):
return raw.lstrip("/")
from urllib.parse import urlparse,unquote

View File

@ -128,6 +128,7 @@ async def fetch_users(
search: str | None = Query(None),
top: int | None = Query(None),
skip: int = Query(0, ge=0),
role_id: int | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
@ -136,8 +137,8 @@ async def fetch_users(
item=await service.get_user_by_id(record_id)
return JSONResponse(content={"data":item,"total":1,"status_code":200})
items=await service.get_users(top,skip,search)
total=await service.count_users(search)
items=await service.get_users(top,skip,search,role_id=role_id)
total=await service.count_users(search,role_id=role_id)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException:
raise

View File

@ -111,6 +111,8 @@ class Users(SQLModel, table=True):
statement = statement.limit(top)
if role_id:
statement = statement.where(cls.role_id == role_id)
if not role_id:
statement = statement.where(cls.role_id != 8)
result = await session.execute(statement)
return result.scalars().all()
@ -135,7 +137,7 @@ class Users(SQLModel, table=True):
uid = cls._as_uuid(record_id)
if uid is None:
return None
statement = select(cls).options(selectinload(cls.role)).where(cls.id == uid)
statement = select(cls).options(selectinload(cls.role)).where(cls.id == uid,cls.role_id != 8)
result = await session.execute(statement)
return result.scalars().first()
@ -146,12 +148,16 @@ class Users(SQLModel, table=True):
return result.scalars().first()
@classmethod
async def count_users(cls, session: AsyncSession, search: str | None):
async def count_users(cls, session: AsyncSession, search: str | None = None, role_id: Optional[int] = None):
statement = (
select(func.count())
.select_from(cls)
.where(cls.is_deleted == False) # noqa: E712
)
if role_id:
statement = statement.where(cls.role_id == role_id)
else:
statement = statement.where(cls.role_id != 8)
if search:
statement = statement.where(cls._search_filter(search))
result = await session.execute(statement)

View File

@ -53,8 +53,7 @@ class User:
fields=clean_user_payload(payload)
if not fields.get("password"):
raise HTTPException(status_code=400,detail="Password is required")
role=await Roles.get_role_by_name(self.session,EnumRoles.CANDIDATE.value)
fields["role_id"]=role.id if role else 4
fields["role_id"]=4
user=await Users.insert_user(self.session,fields)
# Signup lands inactive; the mailed link is what flips is_active.
service=Confirmation(session=self.session)
@ -134,8 +133,8 @@ class User:
]
return data,len(data)
async def count_users(self,search=None):
return await Users.count_users(self.session,search)
async def count_users(self,search=None,role_id=None):
return await Users.count_users(self.session,search,role_id=role_id)
async def authenticate_user(self,email,password):
user=await Users.get_user_by_email(self.session,email)

View File

@ -37,7 +37,7 @@ server {
}
# API-only prefixes (no SPA page at the bare path).
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet)(/|$) {
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3)(/|$) {
proxy_pass http://backend-api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

@ -98,25 +98,33 @@ export function toCandidateView(row) {
* Candidate USER accounts `users` rows filtered by role, not the scored
* `candidates` table. Needs candidates.view.
*
* role_id 4 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.
* role_id 8 is the seeded `candidate` role (id 4 is hiring_manager, the signup
* default). We send it explicitly so a missing param cannot list 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 returns `{data, status_code}` with NO `total` on the list; use
* GET /candidate/fetch/users/count (once on page open) for the pager total;
* - `top`/`skip` page the list; the Candidates screen sends the user's page
* size as `top` and `(page-1)*top` as `skip`;
* - 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.
* server-side. Filtering stays client-side on the fetched page until that
* is fixed.
*/
export function listCandidateUsers({ roleId = 4, top = 500, skip = 0 } = {}) {
export function listCandidateUsers({ roleId = 8, top = 10, skip = 0 } = {}) {
return request('/candidate/fetch/users', {
params: { role_id: roleId, top, skip },
})
}
/** Total candidate-role users. Called once when the Candidates page opens. */
export function countCandidateUsers({ roleId = 8, search } = {}) {
return request('/candidate/fetch/users/count', {
params: { role_id: roleId, search },
})
}
/**
* `users` row -> the row shape the Candidates table renders.
*

View File

@ -41,6 +41,11 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
})
}
/** Unfiltered application total. Called once when Inbox Email opens. */
export function countApplications() {
return request('/inbox/all-applications/count')
}
/**
* One persisted message by id the detail behind an inbox row.
*

View File

@ -26,6 +26,11 @@ export function listFormData({
})
}
/** Unfiltered form_data total for a sheet. Called once when Sheet Forms opens. */
export function countFormData({ sheet } = {}) {
return request('/sheet/form-data/count', { params: { sheet } })
}
/** Tab badge counts for one sheet (or all sheets when sheet omitted). */
export function fetchFormCounts({ sheet } = {}) {
return request('/sheet/form-data/counts', { params: { sheet } })

View File

@ -5,8 +5,8 @@ export function me() {
return request('/users/me')
}
export function list({ record_id, search, top, skip } = {}) {
return request('/users/fetch', { params: { record_id, search, top, skip } })
export function list({ record_id, search, top, skip, roleId } = {}) {
return request('/users/fetch', { params: { record_id, search, top, skip, role_id: roleId } })
}
export function create(body) {
@ -33,14 +33,6 @@ export function remove(recordId) {
return request('/users/delete', { method: 'DELETE', params: { record_id: recordId } })
}
/**
* Hiring-manager directory GET /managers/fetch.
* `department` / `title` / `team_size` are always null until those columns exist.
*/
export function listManagers() {
return request('/managers/fetch')
}
export function toManagerView(row) {
return {
id: row.id,

View File

@ -34,6 +34,8 @@ export const qk = {
formData: (p = {}) => ['mailbox', 'form-data', p],
formRow: (id) => ['mailbox', 'form-row', id],
formCounts: (p = {}) => ['mailbox', 'form-counts', p],
applicationTotal: () => ['mailbox', 'application-total'],
formTotal: (p = {}) => ['mailbox', 'form-total', p],
},
assessments: {
all: () => ['assessments'],
@ -46,7 +48,7 @@ export const qk = {
},
managers: {
all: () => ['managers'],
list: () => ['managers', 'list'],
list: (p = {}) => ['managers', 'list', p],
},
orgSettings: {
all: () => ['orgSettings'],
@ -81,6 +83,7 @@ export const qk = {
candidates: {
all: () => ['candidates'],
list: (p = {}) => ['candidates', 'list', p],
count: (p = {}) => ['candidates', 'count', p],
detail: (id) => ['candidates', 'detail', id],
history: (id, p = {}) => ['candidates', 'history', id, p],
},

View File

@ -14,7 +14,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Pagination, useDataTable } from '../ui/DataTable'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import CandidateProfile from './CandidateProfile'
@ -33,10 +33,10 @@ const EMPTY_FILTERS = { account: '' }
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
/** The seeded `candidate` role (backend/role/models.py::EnumRoles). */
/** Seeded `candidate` role is id 8; id 4 is hiring_manager (signup default). */
const CANDIDATE_ROLE_ID = 8
/* Rows are candidate USER accounts (GET /candidate/fetch/users?role_id=4), not
/* 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
@ -47,10 +47,10 @@ const CANDIDATE_ROLE_ID = 8
The consequence is that the ATS columns have no source on this screen see
toCandidateUserView. Open a candidate to get their score, which the shared
Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */
async function fetchCandidates() {
async function fetchCandidates({ top = DEFAULT_PAGE_SIZE, skip = 0 } = {}) {
const [usersRes, appsRes] = await Promise.all([
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID }),
candidatesApi.list({ limit: 500 }).catch(() => null),
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip }),
candidatesApi.list({ limit: 100 }).catch(() => null),
])
const rows = Array.isArray(usersRes?.data) ? usersRes.data : []
const sourceByUser = new Map()
@ -112,7 +112,29 @@ export default function Candidates() {
const navigate = useNavigate()
const updateCandidates = useSeedMutation('candidates')
const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates })
const [q, setQ] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('recent')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
const skip = (page - 1) * pageSize
const countQuery = useQuery({
queryKey: qk.candidates.count({ roleId: CANDIDATE_ROLE_ID }),
queryFn: async () => {
const res = await candidatesApi.countCandidateUsers({ roleId: CANDIDATE_ROLE_ID })
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
},
staleTime: Infinity,
})
const candidatesQuery = useQuery({
queryKey: qk.candidates.list({ top: pageSize, skip }),
queryFn: () => fetchCandidates({ top: pageSize, skip }),
})
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data])
const jobsById = useMemo(
@ -126,14 +148,6 @@ export default function Candidates() {
gcTime: Infinity,
})
const [q, setQ] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('recent')
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
const jobTitleOf = useCallback(
(c) => jobsById[c.jobId]?.title ?? '—',
[jobsById],
@ -214,7 +228,14 @@ export default function Candidates() {
[],
)
const t = useDataTable({ columns, rows, pageSize: 10 })
const t = useDataTable({ columns, rows, pageSize: Math.max(rows.length, 1) })
const total = countQuery.data ?? 0
const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pages)
useEffect(() => {
if (page > pages) setPage(pages)
}, [page, pages])
const recentChips = recentlyViewed
.slice(0, 6)
@ -280,7 +301,7 @@ export default function Candidates() {
<div>
<h1 className="page-title">Candidates</h1>
<p className="page-sub">
{rows.length} candidate account{rows.length === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}
{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}
</p>
</div>
<div className="page-head-actions">
@ -428,7 +449,18 @@ export default function Candidates() {
</tbody>
</table>
</div>
<Pagination {...t} />
<Pagination
from={total ? (currentPage - 1) * pageSize + 1 : 0}
to={total ? (currentPage - 1) * pageSize + rows.length : 0}
total={total}
page={currentPage}
pages={pages}
setPage={setPage}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => { setPageSize(n); setPage(1) }}
pageSizeMax={500}
/>
</div>
)}
</div>

View File

@ -1,6 +1,8 @@
/* ============================================================
Recruitment Inbox application tabs over GET /inbox/all-applications.
Page size is 10, newest first (order by created_at on the server).
Page size defaults to 10. Pagination (skip/offset) is independent of the
limit control except that page 2 uses the current limit: skip = (page-1)*limit.
Total comes from a count endpoint called once when the page opens.
============================================================ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -10,7 +12,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import { Tabs } from '../ui/Tabs'
import { Pagination, pageWindow } from '../ui/DataTable'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast'
@ -29,7 +31,8 @@ import {
const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates']
/** Sheet Forms have no mailbox read state — no Unread tab on that channel. */
const FORM_TABS = ['All Applications', 'Processed', 'Rejected', 'Duplicates']
const PAGE_SIZE = 10
/** Inbox GET `top` / sheet GET `limit` both cap at 500. */
const PAGE_SIZE_MAX = 500
/** Inbox channel: Outlook email queue vs imported Google Form rows. */
const CHANNELS = [
@ -676,6 +679,7 @@ export default function Inbox() {
const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET)
const [tab, setTab] = useState('All Applications')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [selectedId, setSelectedId] = useState(null)
const [q, setQ] = useState('')
const [assigning, setAssigning] = useState(null)
@ -688,18 +692,18 @@ export default function Inbox() {
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({
...tabFilter,
top: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
top: pageSize,
skip: (page - 1) * pageSize,
...(q.trim() ? { search: q.trim() } : {}),
}), [tabFilter, page, q])
}), [tabFilter, page, pageSize, q])
const formParams = useMemo(() => ({
sheet: formSheet || undefined,
offset: (page - 1) * PAGE_SIZE,
limit: PAGE_SIZE,
offset: (page - 1) * pageSize,
limit: pageSize,
...formTabFilter,
...(q.trim() ? { search: q.trim() } : {}),
}), [formSheet, page, q, formTabFilter])
}), [formSheet, page, pageSize, q, formTabFilter])
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams),
@ -738,6 +742,26 @@ export default function Inbox() {
enabled: isForms,
})
const emailTotalQuery = useQuery({
queryKey: qk.mailbox.applicationTotal(),
queryFn: async () => {
const res = await inboxApi.countApplications()
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
},
enabled: !isForms,
staleTime: Infinity,
})
const formTotalQuery = useQuery({
queryKey: qk.mailbox.formTotal({ sheet: formSheet || undefined }),
queryFn: async () => {
const res = await sheetApi.countFormData({ sheet: formSheet || undefined })
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
},
enabled: isForms,
staleTime: Infinity,
})
// Prefer the imported sheet list; keep the known 2026 tab even when the
// sheets endpoint is still loading so the first paint is not blank.
const formSheetOptions = useMemo(() => {
@ -755,9 +779,6 @@ export default function Inbox() {
const activeQuery = isForms ? formQuery : applicationsQuery
const inbox = activeQuery.data?.rows ?? []
const total = activeQuery.data?.total ?? 0
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const currentPage = Math.min(page, pages)
const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {})
const counts = useMemo(
@ -771,6 +792,15 @@ export default function Inbox() {
[serverCounts],
)
const poolTotal = isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0)
const tabTotal = counts[tab] ?? 0
const countsReady = isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess
const total = q.trim()
? (activeQuery.data?.total ?? 0)
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pages)
const list = inbox
const detailQuery = useQuery({
@ -1090,13 +1120,21 @@ export default function Inbox() {
</div>
{activeQuery.isSuccess && total > 0 && (
<Pagination
from={total ? (currentPage - 1) * PAGE_SIZE + 1 : 0}
to={Math.min(currentPage * PAGE_SIZE, total)}
from={total ? (currentPage - 1) * pageSize + 1 : 0}
to={Math.min(currentPage * pageSize, total)}
total={total}
page={currentPage}
pages={pages}
setPage={(p) => { setPage(p); setSelectedId(null); selection.clear() }}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
pageSizeMax={PAGE_SIZE_MAX}
onPageSizeChange={(n) => {
setPageSize(n)
setPage(1)
setSelectedId(null)
selection.clear()
}}
/>
)}
</div>

View File

@ -14,6 +14,7 @@ import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, PageSizeField } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ProgressBar } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
@ -21,6 +22,8 @@ import * as candidatesApi from '../api/candidates'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
/** Backend GET /candidate/scored/fetch caps `limit` at 100. */
const PAGE_SIZE_MAX = 100
/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
function displayName(name) {
@ -246,12 +249,13 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
export default function JobCandidates({ jobId, jobTitle }) {
const [q, setQ] = useState('')
const [filter, setFilter] = useState('all')
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [viewing, setViewing] = useState(null)
const query = useQuery({
queryKey: qk.candidates.list({ jobId }),
queryKey: qk.candidates.list({ jobId, limit: pageSize }),
queryFn: async () => {
const res = await candidatesApi.listCandidates({ jobId })
const res = await candidatesApi.listCandidates({ jobId, limit: pageSize })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(candidatesApi.toCandidateView)
},
@ -303,6 +307,12 @@ export default function JobCandidates({ jobId, jobTitle }) {
<option value="completed">Scored</option>
<option value="failed">Failed</option>
</select>
<PageSizeField
value={pageSize}
onChange={setPageSize}
max={PAGE_SIZE_MAX}
label="Show"
/>
</div>
</div>
</div>

View File

@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
@ -12,10 +13,17 @@ import * as usersApi from '../api/users'
import * as jobsApi from '../api/jobs'
import * as inboxApi from '../api/inbox'
async function fetchManagers() {
const res = await usersApi.listManagers()
const PAGE_SIZE_MAX = 500
/** Seeded `hiring_manager` role (backend/role/models.py::EnumRoles). */
const HIRING_MANAGER_ROLE_ID = 4
async function fetchManagers({ top = DEFAULT_PAGE_SIZE, skip = 0 } = {}) {
const res = await usersApi.list({ roleId: HIRING_MANAGER_ROLE_ID, top, skip })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(usersApi.toManagerView)
return {
rows: rows.map(usersApi.toManagerView),
total: typeof res?.total === 'number' ? res.total : 0,
}
}
async function fetchJobs() {
@ -31,25 +39,39 @@ export default function Managers() {
const { can } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const managersQuery = useQuery({ queryKey: qk.managers.list(), queryFn: fetchManagers })
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const managers = managersQuery.data ?? []
const jobs = jobsQuery.data ?? []
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [detail, setDetail] = useState(null)
const skip = (page - 1) * pageSize
const managersQuery = useQuery({
queryKey: qk.managers.list({ roleId: HIRING_MANAGER_ROLE_ID, top: pageSize, skip }),
queryFn: () => fetchManagers({ top: pageSize, skip }),
})
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const managers = managersQuery.data?.rows ?? []
const total = managersQuery.data?.total ?? 0
const jobs = jobsQuery.data ?? []
const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pages)
useEffect(() => {
if (page > pages) setPage(pages)
}, [page, pages])
useEffect(() => {
const id = location.state?.openManager
if (id) setDetail(managers.find((m) => m.id === id) ?? null)
}, [location.state, managers])
const totalReqs = managers.reduce((s, m) => s + (m.openReqs || 0), 0)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Hiring Managers</h1>
<p className="page-sub">{managers.length} managers · {totalReqs} active requisitions</p>
<p className="page-sub">
{total} manager{total === 1 ? '' : 's'}
</p>
</div>
</div>
@ -58,39 +80,57 @@ export default function Managers() {
)}
{managersQuery.isError && (
<EmptyState icon="managers" title="Couldnt load hiring managers">
{friendlyAuthError(managersQuery.error, 'This directory needs jobs.view or candidates.view.')}
{friendlyAuthError(managersQuery.error, 'This directory needs rbac_users.view.')}
</EmptyState>
)}
{managersQuery.isSuccess && managers.length === 0 && (
{managersQuery.isSuccess && total === 0 && managers.length === 0 && (
<EmptyState icon="managers" title="No hiring managers">
No accounts currently hold the hiring-manager role.
</EmptyState>
)}
{managersQuery.isSuccess && managers.length > 0 && (
<div className="grid g-3">
{managers.map((m) => (
<div className="card" key={m.id}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
<Avatar name={m.name} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="lr-title">{m.name}</div>
<div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div>
{managersQuery.isSuccess && (managers.length > 0 || total > 0) && (
<>
<div className="grid g-3">
{managers.map((m) => (
<div className="card" key={m.id}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
<Avatar name={m.name} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="lr-title">{m.name}</div>
<div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div>
</div>
</div>
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
</div>
<div className="divider" style={{ margin: '12px 0' }} />
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
<span className="cell-sub"><Icon name="mail" /> {m.email ? m.email.split('@')[0] : '—'}</span>
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
</div>
</div>
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
</div>
<div className="divider" style={{ margin: '12px 0' }} />
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
<span className="cell-sub"><Icon name="mail" /> {m.email ? m.email.split('@')[0] : '—'}</span>
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
</div>
</div>
))}
</div>
{total > 0 && (
<div className="card" style={{ marginTop: 16 }}>
<Pagination
from={total ? (currentPage - 1) * pageSize + 1 : 0}
to={total ? (currentPage - 1) * pageSize + managers.length : 0}
total={total}
page={currentPage}
pages={pages}
setPage={setPage}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => { setPageSize(n); setPage(1) }}
pageSizeMax={PAGE_SIZE_MAX}
/>
</div>
))}
</div>
)}
</>
)}
{detail && (

View File

@ -34,6 +34,7 @@ import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { DEFAULT_PAGE_SIZE, PageSizeField } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import CandidateProfile from './CandidateProfile'
@ -45,8 +46,8 @@ import * as candidatesApi from '../api/candidates'
import * as pipelineApi from '../api/pipeline'
import { avatarColor, departments, initials as initialsOf } from '../data/seed'
/** The seed bucket holds 100 candidates; one template per person, no reuse. */
const FETCH_LIMIT = 100
/** Backend GET /candidate/fetch caps `limit` at 100. */
const PAGE_SIZE_MAX = 100
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
@ -127,6 +128,7 @@ export default function TalentPool() {
const [q, setQ] = useState('')
const [dept, setDept] = useState('')
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const navigate = useNavigate()
@ -139,8 +141,8 @@ export default function TalentPool() {
}
const query = useQuery({
queryKey: qk.candidates.list({ limit: FETCH_LIMIT }),
queryFn: () => candidatesApi.list({ limit: FETCH_LIMIT }),
queryKey: qk.candidates.list({ limit: pageSize }),
queryFn: () => candidatesApi.list({ limit: pageSize }),
})
const pool = useMemo(
@ -227,6 +229,12 @@ export default function TalentPool() {
<option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)}
</select>
<PageSizeField
value={pageSize}
onChange={setPageSize}
max={PAGE_SIZE_MAX}
label="Show"
/>
</div>
</div>
</div>

View File

@ -608,6 +608,16 @@ table.data tbody tr:last-child td { border-bottom: none; }
.pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-top: 1px solid var(--border); flex-wrap: wrap; gap: 12px; }
.page-info { font-size: 13px; color: var(--text-2); }
.page-controls { display: flex; gap: 4px; align-items: center; }
.page-size { display: flex; align-items: center; gap: 8px; margin-right: 8px; flex-shrink: 0; }
.page-size-label { font-size: 13px; color: var(--text-2); white-space: nowrap; }
.page-size-select { height: 34px; padding: 0 28px 0 10px; font-size: 13px; }
.page-size-input {
width: 72px; height: 34px; padding: 0 8px; font-size: 13px; font-weight: 600;
text-align: center; border-radius: 8px; border: 1px solid var(--border-strong);
background: var(--bg-elev); color: inherit; outline: none;
}
.page-size-input:focus { border-color: var(--primary); box-shadow: var(--ring); }
.page-size-total { font-size: 13px; color: var(--text-2); margin-right: 8px; white-space: nowrap; }
.page-btn { min-width: 34px; height: 34px; padding: 0 8px; border-radius: 8px; display: grid; place-items: center; font-size: 13px; font-weight: 600; color: var(--text-2); border: 1px solid transparent; }
.page-btn:hover:not(:disabled) { background: var(--bg-sunken); }
.page-btn.active { background: var(--primary); color: var(--primary-fg); }

View File

@ -7,20 +7,30 @@
useDataTable alone. The other six consumers use <DataTable/>.
Sort comparator and the ellipsis pager windowing are ported verbatim.
Client-side sort/paginate is retained deliberately there are no paginated
list endpoints to bind to yet outside /users/fetch.
Page size defaults to 10 (the GET `top`/`limit` default) and is user-settable;
screens that paginate on the server pass the same value as the query param.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import Icon from './icons'
import { EmptyState } from './primitives'
export function useDataTable({ columns, rows, pageSize = 10 }) {
/** Matches the backend Query(10) default on list GET endpoints. */
export const DEFAULT_PAGE_SIZE = 10
export function clampPageSize(value, max = 100) {
const n = Number.parseInt(value, 10)
if (!Number.isFinite(n) || n < 1) return DEFAULT_PAGE_SIZE
return Math.min(n, max)
}
export function useDataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE }) {
const [sort, setSort] = useState({ key: null, dir: 1 })
const [page, setPage] = useState(1)
const size = Math.max(1, pageSize || DEFAULT_PAGE_SIZE)
// The prototype reset to page 1 inside its imperative update(rows).
useEffect(() => setPage(1), [rows])
useEffect(() => setPage(1), [rows, size])
const sorted = useMemo(() => {
if (!sort.key) return rows
@ -39,23 +49,23 @@ export function useDataTable({ columns, rows, pageSize = 10 }) {
}, [rows, columns, sort])
const total = sorted.length
const pages = Math.max(1, Math.ceil(total / pageSize))
const pages = Math.max(1, Math.ceil(total / size))
const current = Math.min(page, pages)
const start = (current - 1) * pageSize
const start = (current - 1) * size
function toggleSort(key) {
setSort((s) => (s.key === key ? { key, dir: s.dir * -1 } : { key, dir: 1 }))
}
return {
pageRows: sorted.slice(start, start + pageSize),
pageRows: sorted.slice(start, start + size),
sort,
toggleSort,
page: current,
pages,
setPage,
from: total ? start + 1 : 0,
to: Math.min(start + pageSize, total),
to: Math.min(start + size, total),
total,
pageButtons: pageWindow(current, pages),
}
@ -71,13 +81,61 @@ export function pageWindow(cur, pages) {
return list
}
export function Pagination({ from, to, total, page, pages, setPage, pageButtons }) {
/** Local draft so typing "50" does not fire a GET for 5, then 50. */
export function PageSizeField({ value, onChange, max = 100, label = 'Per page', id }) {
const [draft, setDraft] = useState(String(value ?? DEFAULT_PAGE_SIZE))
useEffect(() => setDraft(String(value ?? DEFAULT_PAGE_SIZE)), [value])
function commit() {
const next = clampPageSize(draft, max)
setDraft(String(next))
if (next !== value) onChange(next)
}
return (
<label className="page-size">
<span className="page-size-label">{label}</span>
<input
id={id}
className="page-size-input"
type="number"
min={1}
max={max}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
}
}}
aria-label={label}
/>
</label>
)
}
export function Pagination({
from, to, total, page, pages, setPage, pageButtons,
pageSize, onPageSizeChange, pageSizeMax = 100,
}) {
return (
<div className="pagination">
<span className="page-info">
Showing <b>{from}{to}</b> of <b>{total}</b>
</span>
<div className="page-controls">
{onPageSizeChange && (
<>
<PageSizeField
value={pageSize ?? DEFAULT_PAGE_SIZE}
onChange={onPageSizeChange}
max={pageSizeMax}
/>
<span className="page-size-total">of <b>{total}</b></span>
</>
)}
<button className="page-btn" disabled={page === 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<Icon name="chevron-left" />
</button>
@ -103,8 +161,9 @@ export function Pagination({ from, to, total, page, pages, setPage, pageButtons
)
}
export default function DataTable({ columns, rows, pageSize = 10, empty }) {
const t = useDataTable({ columns, rows, pageSize })
export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE, pageSizeMax = 100, empty }) {
const [size, setSize] = useState(pageSize)
const t = useDataTable({ columns, rows, pageSize: size })
return (
<div className="dt">
@ -157,7 +216,7 @@ export default function DataTable({ columns, rows, pageSize = 10, empty }) {
</tbody>
</table>
</div>
<Pagination {...t} />
<Pagination {...t} pageSize={size} onPageSizeChange={setSize} pageSizeMax={pageSizeMax} />
</div>
)
}

View File

@ -21,7 +21,7 @@ export default defineConfig({
// Same-origin style for local Vite when VITE_API_BASE is empty.
// Requires API published on the host (docker-compose.host-ports.yml).
proxy: {
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox)(/|$)': {
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3)(/|$)': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},