diff --git a/.dockerignore b/.dockerignore index 3ccd3d4..2ba274f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -42,3 +42,5 @@ tools/ *.log tmp/ temp/ +tests/** +/backend/tests/** \ No newline at end of file diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py index a48c46a..00d8336 100644 --- a/backend/employment_agent/decorators.py +++ b/backend/employment_agent/decorators.py @@ -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"), + } diff --git a/backend/employment_agent/execute_agent.py b/backend/employment_agent/execute_agent.py index 53f7f32..8d15f90 100644 --- a/backend/employment_agent/execute_agent.py +++ b/backend/employment_agent/execute_agent.py @@ -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) diff --git a/backend/employment_agent/plugins.py b/backend/employment_agent/plugins.py new file mode 100644 index 0000000..05b1904 --- /dev/null +++ b/backend/employment_agent/plugins.py @@ -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")} diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py index 58cc8eb..15f56c6 100644 --- a/backend/employment_agent/prompt.py +++ b/backend/employment_agent/prompt.py @@ -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" }} """ diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index 7a8c737..a3a97f4 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -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, diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index 6186013..eb94994 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -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) diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 1ca96ea..5fc5e70 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -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)), diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 5400540..d6a5230 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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, diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index cdb6965..9e688cd 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -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()] diff --git a/backend/inbox/tasks.py b/backend/inbox/tasks.py index cb77802..aa8e9d7 100644 --- a/backend/inbox/tasks.py +++ b/backend/inbox/tasks.py @@ -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( diff --git a/backend/job/app.py b/backend/job/app.py index c67c05c..c0330a7 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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(...), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 205310e..da167ba 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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, diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 04d5535..9e47f41 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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(), diff --git a/backend/s3/plugins.py b/backend/s3/plugins.py index 4e0e429..644e054 100644 --- a/backend/s3/plugins.py +++ b/backend/s3/plugins.py @@ -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 diff --git a/backend/users/app.py b/backend/users/app.py index 11dd187..fac26eb 100644 --- a/backend/users/app.py +++ b/backend/users/app.py @@ -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 diff --git a/backend/users/models.py b/backend/users/models.py index 1b41675..9415e00 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -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) diff --git a/backend/users/views.py b/backend/users/views.py index 4df2e17..55d6dfc 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -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) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 139069d..dd9d8de 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 1fa93cc..5f5ca5f 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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. * diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index d8a3acc..cb2e16b 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -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. * diff --git a/frontend/src/api/sheet.js b/frontend/src/api/sheet.js index f40a05a..99d18a0 100644 --- a/frontend/src/api/sheet.js +++ b/frontend/src/api/sheet.js @@ -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 } }) diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js index 2ac9504..d5e4217 100644 --- a/frontend/src/api/users.js +++ b/frontend/src/api/users.js @@ -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, diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 998a6e2..5ff463c 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -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], }, diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 8aa609a..46cc3b5 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -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() {

Candidates

- {rows.length} candidate account{rows.length === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID} + {total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}

@@ -428,7 +449,18 @@ export default function Candidates() {
- + { setPageSize(n); setPage(1) }} + pageSizeMax={500} + /> )} diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 4aef859..aecb035 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -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() { {activeQuery.isSuccess && total > 0 && ( { 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() + }} /> )} diff --git a/frontend/src/screens/JobCandidates.jsx b/frontend/src/screens/JobCandidates.jsx index 74c83a7..7dc45aa 100644 --- a/frontend/src/screens/JobCandidates.jsx +++ b/frontend/src/screens/JobCandidates.jsx @@ -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 }) { + diff --git a/frontend/src/screens/Managers.jsx b/frontend/src/screens/Managers.jsx index 01ef2c7..061903c 100644 --- a/frontend/src/screens/Managers.jsx +++ b/frontend/src/screens/Managers.jsx @@ -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 (

Hiring Managers

-

{managers.length} managers · {totalReqs} active requisitions

+

+ {total} manager{total === 1 ? '' : 's'} +

@@ -58,39 +80,57 @@ export default function Managers() { )} {managersQuery.isError && ( - {friendlyAuthError(managersQuery.error, 'This directory needs jobs.view or candidates.view.')} + {friendlyAuthError(managersQuery.error, 'This directory needs rbac_users.view.')} )} - {managersQuery.isSuccess && managers.length === 0 && ( + {managersQuery.isSuccess && total === 0 && managers.length === 0 && ( No accounts currently hold the hiring-manager role. )} - {managersQuery.isSuccess && managers.length > 0 && ( -
- {managers.map((m) => ( -
-
-
- -
-
{m.name}
-
{m.title || m.roleName || 'Hiring manager'}
+ {managersQuery.isSuccess && (managers.length > 0 || total > 0) && ( + <> +
+ {managers.map((m) => ( +
+
+
+ +
+
{m.name}
+
{m.title || m.roleName || 'Hiring manager'}
+
+
+
+
{m.openReqs}Open Reqs
+
{m.teamSize ?? '—'}Team Size
+
+
+
+ {m.email ? m.email.split('@')[0] : '—'} +
-
-
{m.openReqs}Open Reqs
-
{m.teamSize ?? '—'}Team Size
-
-
-
- {m.email ? m.email.split('@')[0] : '—'} - -
+ ))} +
+ {total > 0 && ( +
+ { setPageSize(n); setPage(1) }} + pageSizeMax={PAGE_SIZE_MAX} + />
- ))} -
+ )} + )} {detail && ( diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index d76528f..3a3eb4b 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -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() { {departments.map((d) => )} +
diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 02fbaba..2fbc9d9 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -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); } diff --git a/frontend/src/ui/DataTable.jsx b/frontend/src/ui/DataTable.jsx index c3be4c4..15bd8a6 100644 --- a/frontend/src/ui/DataTable.jsx +++ b/frontend/src/ui/DataTable.jsx @@ -7,20 +7,30 @@ useDataTable alone. The other six consumers use . 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 ( + + ) +} + +export function Pagination({ + from, to, total, page, pages, setPage, pageButtons, + pageSize, onPageSizeChange, pageSizeMax = 100, +}) { return (
Showing {from}–{to} of {total}
+ {onPageSizeChange && ( + <> + + of {total} + + )} @@ -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 (
@@ -157,7 +216,7 @@ export default function DataTable({ columns, rows, pageSize = 10, empty }) {
- +
) } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2f34a4e..b836591 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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, },