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 *.log
tmp/ tmp/
temp/ 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 Mirrors job/candidate/decorators.py stacked wrappers that clean LLM output
before the task persists it: before the task persists it:
raw JSON -> require_json_object -> clamp_company_to_resume parse_employment_response -> clamp_phone -> prefer_extracted_phone
-> clamp_education_to_resume -> clamp_linkedin_url -> clamp_linkedin_url -> clamp_education_to_resume
-> parse_employment_response -> 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 from __future__ import annotations
import re
from functools import wraps 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): def require_json_object(func):
@ -28,81 +32,96 @@ def require_json_object(func):
return wrapper return wrapper
def clamp_company_to_resume(func): def clamp_field(key,clean):
"""Keep company only when it appears in resume_text; else NO_COMPANY.""" """Run `clean(value, resume_text)` on one dict key; leave the rest alone."""
@wraps(func) def decorator(func):
def wrapper(data,resume_text="",*args,**kwargs): @wraps(func)
company,education,current_title,linkedin_url=func(data,resume_text,*args,**kwargs) def wrapper(data,resume_text="",*args,**kwargs):
company=(company or "").strip() fields=func(data,resume_text,*args,**kwargs)
if not company or company.lower()==NO_COMPANY.lower(): fields[key]=clean(fields.get(key),resume_text)
return NO_COMPANY,education,current_title,linkedin_url 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() haystack=(resume_text or "").lower()
if company.lower() not in haystack: if text.lower() not in haystack:
return NO_COMPANY,education,current_title,linkedin_url return sentinel
return company,education,current_title,linkedin_url return text
return clamp_field(key,clean)
return wrapper
def clamp_education_to_resume(func): def _clean_linkedin(value,resume_text):
"""Keep education only when it appears in resume_text; else EDUCATION.""" 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) @wraps(func)
def wrapper(data,resume_text="",*args,**kwargs): def wrapper(data,resume_text="",*args,**kwargs):
company,education,current_title,linkedin_url=func(data,resume_text,*args,**kwargs) fields=func(data,resume_text,*args,**kwargs)
education=(education or "").strip() from employment_agent.plugins import prefer_full_phone,scan_phone
if not education or education.lower()==EDUCATION.lower(): fields["phone"]=prefer_full_phone(fields.get("phone"),scan_phone(resume_text))
return company,EDUCATION,current_title,linkedin_url return fields
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
return wrapper return wrapper
def clamp_linkedin_url(func): clamp_company_to_resume=clamp_in_resume("current_employment",NO_COMPANY)
"""Keep linkedin_url only when the model returned a LinkedIn profile URL. clamp_education_to_resume=clamp_in_resume("education",EDUCATION)
clamp_linkedin_url=clamp_field("linkedin_url",_clean_linkedin)
This is output validation, not CV scanning: the URL is the agent's own clamp_phone=clamp_field("phone",_clean_phone)
`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
@require_json_object @require_json_object
@clamp_company_to_resume @clamp_company_to_resume
@clamp_education_to_resume @clamp_education_to_resume
@clamp_linkedin_url @clamp_linkedin_url
def parse_employment_response(data,resume_text:str="") -> tuple[str,str,str,str|None]: @prefer_extracted_phone
"""Pull company, education, title, and linkedin_url from the agent JSON.""" @clamp_phone
current=data.get("current_employment") def parse_employment_response(data,resume_text=""):
education=data.get("education") """Pull company, education, title, linkedin_url, and phone from the agent JSON."""
current_title=data.get("current_title") def as_str(key):
linkedin_url=data.get("linkedin_url") value=data.get(key)
if not isinstance(current,str): return value.strip() if isinstance(value,str) else ""
current="" return {
if not isinstance(education,str): "current_employment":as_str("current_employment"),
education="" "education":as_str("education"),
if not isinstance(current_title,str): "current_title":as_str("current_title"),
current_title="" "linkedin_url":as_str("linkedin_url"),
if not isinstance(linkedin_url,str): "phone":as_str("phone"),
linkedin_url="" }
return current.strip(),education.strip(),current_title.strip(),linkedin_url.strip()

View File

@ -15,10 +15,16 @@ from llm_setup import llm_call
logger=logging.getLogger("employment_agent") 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() text=(resume_text or "").strip()
if not text: 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: try:
data=await llm_call(prompt(),user_prompt(text),json_mode=True) data=await llm_call(prompt(),user_prompt(text),json_mode=True)
return parse_employment_response(data,text) 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" EDUCATION="No Education Mentioned"
CURRENT_TITLE="No JOB POSITION MENTIONED" CURRENT_TITLE="No JOB POSITION MENTIONED"
NO_LINKEDIN="no linkedin url mentioned" NO_LINKEDIN="no linkedin url mentioned"
NO_PHONE="no phone number mentioned"
def prompt(): def prompt():
return f"""You are an HR-ATS recruiting assistant. return f"""You are an HR-ATS recruiting assistant.
You are given CV/resume text. Identify the candidate's CURRENT employer company You are given CV/resume text. Identify the candidate's CURRENT employer company
name, their education (degree / school), their current job title, and their name, their education (degree / school), their current job title, their
LinkedIn profile URL when present. LinkedIn profile URL, and their phone number when present.
Rules: Rules:
- Return only the company name that appears in the resume text for the ongoing / most recent role. - 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/...). - 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"). - 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. - 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 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} - 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: Respond with JSON only:
{{ {{
"current_employment": "Company Name", "current_employment": "Company Name",
"education": "Degree / School", "education": "Degree / School",
"current_title": "Job Title", "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), processing_state: str | None = Query(None),
is_duplicate: bool | None = Query(None), is_duplicate: bool | None = Query(None),
offset: int = Query(0,ge=0), 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), current_user: dict = Depends(_FORM_DATA_READ),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
@ -230,6 +231,23 @@ async def fetch_form_data_counts(
raise HTTPException(status_code=500,detail=str(e)) 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}") @router.get("/sheet/form-data/{record_id}")
async def fetch_form_data_by_id( async def fetch_form_data_by_id(
record_id: str, record_id: str,

View File

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

View File

@ -145,7 +145,7 @@ async def fetch_email_sync(
async def fetch_inbox( async def fetch_inbox(
record_id: str | None = Query(None), record_id: str | None = Query(None),
search: 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), skip: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
@ -283,7 +283,8 @@ async def get_all_applications(
assigned: bool | None = Query(default=None), assigned: bool | None = Query(default=None),
is_duplicate: bool | None = Query(default=None), is_duplicate: bool | None = Query(default=None),
search: str | None = Query(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), skip: int = Query(0, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@ -312,6 +313,22 @@ async def get_all_applications(
raise HTTPException(status_code=500,detail=str(e)) 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") @router.get("/inbox/counts")
async def get_inbox_counts( async def get_inbox_counts(
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)), 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 # Placeholder only. The account lands inactive and the candidate is mailed a
# confirmation link; the real password comes from the reset flow afterwards. # confirmation link; the real password comes from the reset flow afterwards.
DEFAULT_CANDIDATE_PASSWORD = os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#") 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", SKIP_SENDER_PREFIXES = ("noreply", "no-reply", "donotreply", "do-not-reply",
"mailer-daemon", "postmaster", "bounce") "mailer-daemon", "postmaster", "bounce")
@ -570,11 +570,10 @@ class Inbox_Messages(SQLModel, table=True):
)).scalar_one_or_none() )).scalar_one_or_none()
if user_id is None: if user_id is None:
role=await Roles.get_role_by_name(session,EnumRoles.CANDIDATE.value)
user=Users( user=Users(
name=cls._sender_display_name(email_data,address), name=cls._sender_display_name(email_data,address),
email=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), password=hash_password(DEFAULT_CANDIDATE_PASSWORD),
) )
session.add(user) session.add(user)
@ -727,8 +726,9 @@ class Inbox_Messages(SQLModel, table=True):
async def get_inbox_messages( 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 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 # Page size is the caller's `top` (Inbox sends 10/25/50/100); `skip` is
# so page 1 -> 0..9, page 2 -> 10..19. Newest first via created_at. # (page-1)*top so page 1 of 25 -> 0..24, page 2 -> 25..49. Newest first
# via created_at.
statement = cls._apply_filters( statement = cls._apply_filters(
select(cls).order_by(cls.created_at.desc()), select(cls).order_by(cls.created_at.desc()),
search, isread, application_status, assigned, is_duplicate, search, isread, application_status, assigned, is_duplicate,

View File

@ -6,7 +6,6 @@ import asyncio
import base64 import base64
import logging import logging
import os import os
import re
import uuid import uuid
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@ -32,11 +31,6 @@ TEAMS_API_TOKEN=os.getenv("TEAMS_API_TOKEN")
MAIL_ACCEPTED_STATUS=202 MAIL_ACCEPTED_STATUS=202
_ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments" _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): async def request_email_confirmation(email):
@ -216,13 +210,6 @@ async def attach_email_pdfs_to_s3(session,row,pdfs,*,created_new:bool):
raise 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]: async def extract_resume_text(file_paths:list[str]) -> tuple[str,str]:
"""Extract text from S3 URLs or leftover local PDF paths.""" """Extract text from S3 URLs or leftover local PDF paths."""
refs=[p.strip() for p in (file_paths or []) if p and p.strip()] 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 db_setup import session_scope
from employment_agent.execute_agent import run_employment_agent from employment_agent.execute_agent import run_employment_agent
from inbox.models import Inbox_Messages,Inbox,AtsResults 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.models import JobPosts
from job.job_post.serializers import serialize_job_post from job.job_post.serializers import serialize_job_post
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY,broker 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] job_posts=[serialize_job_post(p) for p in posts]
text,extract_err=await extract_resume_text(paths) text,extract_err=await extract_resume_text(paths)
phone=extract_phone(text) if text else None
if not text: if not text:
async with session_scope() as session: async with session_scope() as session:
await Inbox_Messages.set_match_result( 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": if status=="failed":
raise RuntimeError(result.get("error") or "agent returned failed status") 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}", 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: async with session_scope() as session:
await Inbox_Messages.set_match_result( await Inbox_Messages.set_match_result(

View File

@ -220,10 +220,10 @@ async def create_manual_candidate(
@router.get("/candidate/fetch/users") @router.get("/candidate/fetch/users")
async def fetch_users( async def fetch_users(
role_id:int=Query(4), role_id:Optional[int]=Query(None),
top:int=Query(10), top:Optional[int]=Query(None),
skip:int=Query(0), skip:Optional[int]=Query(None),
search:str=Query(None), search:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
@ -234,6 +234,22 @@ async def fetch_users(
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(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") @router.post("/candidate/cv_upload")
async def cv_upload( async def cv_upload(
file: UploadFile = File(...), 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): async def create_manual_upload_candidate(cls, session: AsyncSession, fields: dict):
import os import os
from role.models import EnumRoles, Roles
from users.models import Users from users.models import Users
from users.plugins import hash_password 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) user=await Users.get_user_by_email(session,email)
if not user: if not user:
role=await Roles.get_role_by_name(session,EnumRoles.CANDIDATE.value)
user=await Users.insert_user(session,{ user=await Users.insert_user(session,{
"name":name, "name":name,
"email":email, "email":email,
"role_id":role.id if role else 4, "role_id":8,
"password":hash_password(default_pw), "password":hash_password(default_pw),
"is_active":True, "is_active":True,
"is_deleted":False, "is_deleted":False,

View File

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

View File

@ -278,6 +278,13 @@ class S3:
raw=(url or "").strip() raw=(url or "").strip()
if not raw: if not raw:
raise S3ServiceError("url is required",status_code=422) 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): if not self.is_http_url(raw):
return raw.lstrip("/") return raw.lstrip("/")
from urllib.parse import urlparse,unquote from urllib.parse import urlparse,unquote

View File

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

View File

@ -111,6 +111,8 @@ class Users(SQLModel, table=True):
statement = statement.limit(top) statement = statement.limit(top)
if role_id: if role_id:
statement = statement.where(cls.role_id == 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) result = await session.execute(statement)
return result.scalars().all() return result.scalars().all()
@ -135,7 +137,7 @@ class Users(SQLModel, table=True):
uid = cls._as_uuid(record_id) uid = cls._as_uuid(record_id)
if uid is None: if uid is None:
return 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) result = await session.execute(statement)
return result.scalars().first() return result.scalars().first()
@ -146,12 +148,16 @@ class Users(SQLModel, table=True):
return result.scalars().first() return result.scalars().first()
@classmethod @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 = ( statement = (
select(func.count()) select(func.count())
.select_from(cls) .select_from(cls)
.where(cls.is_deleted == False) # noqa: E712 .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: if search:
statement = statement.where(cls._search_filter(search)) statement = statement.where(cls._search_filter(search))
result = await session.execute(statement) result = await session.execute(statement)

View File

@ -53,8 +53,7 @@ class User:
fields=clean_user_payload(payload) fields=clean_user_payload(payload)
if not fields.get("password"): if not fields.get("password"):
raise HTTPException(status_code=400,detail="Password is required") raise HTTPException(status_code=400,detail="Password is required")
role=await Roles.get_role_by_name(self.session,EnumRoles.CANDIDATE.value) fields["role_id"]=4
fields["role_id"]=role.id if role else 4
user=await Users.insert_user(self.session,fields) user=await Users.insert_user(self.session,fields)
# Signup lands inactive; the mailed link is what flips is_active. # Signup lands inactive; the mailed link is what flips is_active.
service=Confirmation(session=self.session) service=Confirmation(session=self.session)
@ -134,8 +133,8 @@ class User:
] ]
return data,len(data) return data,len(data)
async def count_users(self,search=None): async def count_users(self,search=None,role_id=None):
return await Users.count_users(self.session,search) return await Users.count_users(self.session,search,role_id=role_id)
async def authenticate_user(self,email,password): async def authenticate_user(self,email,password):
user=await Users.get_user_by_email(self.session,email) 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). # 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_pass http://backend-api:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; 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 * Candidate USER accounts `users` rows filtered by role, not the scored
* `candidates` table. Needs candidates.view. * `candidates` table. Needs candidates.view.
* *
* role_id 4 is the seeded `candidate` role (backend/role/models.py::EnumRoles); * role_id 8 is the seeded `candidate` role (id 4 is hiring_manager, the signup
* the route defaults to it, and we send it explicitly so a re-seed that renumbers * default). We send it explicitly so a missing param cannot list the wrong people.
* the roles fails loudly here rather than silently listing the wrong people.
* *
* Three things this route does NOT do, all verified against * Three things this route does NOT do, all verified against
* backend/job/app.py::fetch_users: * backend/job/app.py::fetch_users:
* - it returns `{data, status_code}` with NO `total`, so a caller cannot show a * - it returns `{data, status_code}` with NO `total` on the list; use
* row count or drive server-side pagination from the response alone; * GET /candidate/fetch/users/count (once on page open) for the pager total;
* - `top` defaults to 10, so omitting it silently truncates to ten rows; * - `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 * - 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 * 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', { return request('/candidate/fetch/users', {
params: { role_id: roleId, top, skip }, 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. * `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. * 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). */ /** Tab badge counts for one sheet (or all sheets when sheet omitted). */
export function fetchFormCounts({ sheet } = {}) { export function fetchFormCounts({ sheet } = {}) {
return request('/sheet/form-data/counts', { params: { sheet } }) return request('/sheet/form-data/counts', { params: { sheet } })

View File

@ -5,8 +5,8 @@ export function me() {
return request('/users/me') return request('/users/me')
} }
export function list({ record_id, search, top, skip } = {}) { export function list({ record_id, search, top, skip, roleId } = {}) {
return request('/users/fetch', { params: { record_id, search, top, skip } }) return request('/users/fetch', { params: { record_id, search, top, skip, role_id: roleId } })
} }
export function create(body) { export function create(body) {
@ -33,14 +33,6 @@ export function remove(recordId) {
return request('/users/delete', { method: 'DELETE', params: { record_id: 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) { export function toManagerView(row) {
return { return {
id: row.id, id: row.id,

View File

@ -34,6 +34,8 @@ export const qk = {
formData: (p = {}) => ['mailbox', 'form-data', p], formData: (p = {}) => ['mailbox', 'form-data', p],
formRow: (id) => ['mailbox', 'form-row', id], formRow: (id) => ['mailbox', 'form-row', id],
formCounts: (p = {}) => ['mailbox', 'form-counts', p], formCounts: (p = {}) => ['mailbox', 'form-counts', p],
applicationTotal: () => ['mailbox', 'application-total'],
formTotal: (p = {}) => ['mailbox', 'form-total', p],
}, },
assessments: { assessments: {
all: () => ['assessments'], all: () => ['assessments'],
@ -46,7 +48,7 @@ export const qk = {
}, },
managers: { managers: {
all: () => ['managers'], all: () => ['managers'],
list: () => ['managers', 'list'], list: (p = {}) => ['managers', 'list', p],
}, },
orgSettings: { orgSettings: {
all: () => ['orgSettings'], all: () => ['orgSettings'],
@ -81,6 +83,7 @@ export const qk = {
candidates: { candidates: {
all: () => ['candidates'], all: () => ['candidates'],
list: (p = {}) => ['candidates', 'list', p], list: (p = {}) => ['candidates', 'list', p],
count: (p = {}) => ['candidates', 'count', p],
detail: (id) => ['candidates', 'detail', id], detail: (id) => ['candidates', 'detail', id],
history: (id, p = {}) => ['candidates', 'history', id, p], 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' 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 { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import CandidateProfile from './CandidateProfile' 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. */ /** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] 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 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. rows of the scored `candidates` table.
Why: /candidate/scored/fetch only ever returns CVs that have been through the 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 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 toCandidateUserView. Open a candidate to get their score, which the shared
Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */ 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([ const [usersRes, appsRes] = await Promise.all([
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID }), candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip }),
candidatesApi.list({ limit: 500 }).catch(() => null), candidatesApi.list({ limit: 100 }).catch(() => null),
]) ])
const rows = Array.isArray(usersRes?.data) ? usersRes.data : [] const rows = Array.isArray(usersRes?.data) ? usersRes.data : []
const sourceByUser = new Map() const sourceByUser = new Map()
@ -112,7 +112,29 @@ export default function Candidates() {
const navigate = useNavigate() const navigate = useNavigate()
const updateCandidates = useSeedMutation('candidates') 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 jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data]) const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data])
const jobsById = useMemo( const jobsById = useMemo(
@ -126,14 +148,6 @@ export default function Candidates() {
gcTime: Infinity, 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( const jobTitleOf = useCallback(
(c) => jobsById[c.jobId]?.title ?? '—', (c) => jobsById[c.jobId]?.title ?? '—',
[jobsById], [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 const recentChips = recentlyViewed
.slice(0, 6) .slice(0, 6)
@ -280,7 +301,7 @@ export default function Candidates() {
<div> <div>
<h1 className="page-title">Candidates</h1> <h1 className="page-title">Candidates</h1>
<p className="page-sub"> <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> </p>
</div> </div>
<div className="page-head-actions"> <div className="page-head-actions">
@ -428,7 +449,18 @@ export default function Candidates() {
</tbody> </tbody>
</table> </table>
</div> </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>
)} )}
</div> </div>

View File

@ -1,6 +1,8 @@
/* ============================================================ /* ============================================================
Recruitment Inbox application tabs over GET /inbox/all-applications. 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' 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 Modal from '../ui/Modal'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import { Tabs } from '../ui/Tabs' 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 { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -29,7 +31,8 @@ import {
const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates'] const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates']
/** Sheet Forms have no mailbox read state — no Unread tab on that channel. */ /** Sheet Forms have no mailbox read state — no Unread tab on that channel. */
const FORM_TABS = ['All Applications', 'Processed', 'Rejected', 'Duplicates'] 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. */ /** Inbox channel: Outlook email queue vs imported Google Form rows. */
const CHANNELS = [ const CHANNELS = [
@ -676,6 +679,7 @@ export default function Inbox() {
const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET) const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET)
const [tab, setTab] = useState('All Applications') const [tab, setTab] = useState('All Applications')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [selectedId, setSelectedId] = useState(null) const [selectedId, setSelectedId] = useState(null)
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [assigning, setAssigning] = useState(null) const [assigning, setAssigning] = useState(null)
@ -688,18 +692,18 @@ export default function Inbox() {
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {} const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({ const listParams = useMemo(() => ({
...tabFilter, ...tabFilter,
top: PAGE_SIZE, top: pageSize,
skip: (page - 1) * PAGE_SIZE, skip: (page - 1) * pageSize,
...(q.trim() ? { search: q.trim() } : {}), ...(q.trim() ? { search: q.trim() } : {}),
}), [tabFilter, page, q]) }), [tabFilter, page, pageSize, q])
const formParams = useMemo(() => ({ const formParams = useMemo(() => ({
sheet: formSheet || undefined, sheet: formSheet || undefined,
offset: (page - 1) * PAGE_SIZE, offset: (page - 1) * pageSize,
limit: PAGE_SIZE, limit: pageSize,
...formTabFilter, ...formTabFilter,
...(q.trim() ? { search: q.trim() } : {}), ...(q.trim() ? { search: q.trim() } : {}),
}), [formSheet, page, q, formTabFilter]) }), [formSheet, page, pageSize, q, formTabFilter])
const applicationsQuery = useQuery({ const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams), queryKey: qk.mailbox.applications(listParams),
@ -738,6 +742,26 @@ export default function Inbox() {
enabled: isForms, 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 // 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. // sheets endpoint is still loading so the first paint is not blank.
const formSheetOptions = useMemo(() => { const formSheetOptions = useMemo(() => {
@ -755,9 +779,6 @@ export default function Inbox() {
const activeQuery = isForms ? formQuery : applicationsQuery const activeQuery = isForms ? formQuery : applicationsQuery
const inbox = activeQuery.data?.rows ?? [] 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 serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {})
const counts = useMemo( const counts = useMemo(
@ -771,6 +792,15 @@ export default function Inbox() {
[serverCounts], [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 list = inbox
const detailQuery = useQuery({ const detailQuery = useQuery({
@ -1090,13 +1120,21 @@ export default function Inbox() {
</div> </div>
{activeQuery.isSuccess && total > 0 && ( {activeQuery.isSuccess && total > 0 && (
<Pagination <Pagination
from={total ? (currentPage - 1) * PAGE_SIZE + 1 : 0} from={total ? (currentPage - 1) * pageSize + 1 : 0}
to={Math.min(currentPage * PAGE_SIZE, total)} to={Math.min(currentPage * pageSize, total)}
total={total} total={total}
page={currentPage} page={currentPage}
pages={pages} pages={pages}
setPage={(p) => { setPage(p); setSelectedId(null); selection.clear() }} setPage={(p) => { setPage(p); setSelectedId(null); selection.clear() }}
pageButtons={pageWindow(currentPage, pages)} pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
pageSizeMax={PAGE_SIZE_MAX}
onPageSizeChange={(n) => {
setPageSize(n)
setPage(1)
setSelectedId(null)
selection.clear()
}}
/> />
)} )}
</div> </div>

View File

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

View File

@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
@ -12,10 +13,17 @@ import * as usersApi from '../api/users'
import * as jobsApi from '../api/jobs' import * as jobsApi from '../api/jobs'
import * as inboxApi from '../api/inbox' import * as inboxApi from '../api/inbox'
async function fetchManagers() { const PAGE_SIZE_MAX = 500
const res = await usersApi.listManagers() /** 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 : [] 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() { async function fetchJobs() {
@ -31,25 +39,39 @@ export default function Managers() {
const { can } = useAuth() const { can } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const managersQuery = useQuery({ queryKey: qk.managers.list(), queryFn: fetchManagers }) const [page, setPage] = useState(1)
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const managers = managersQuery.data ?? []
const jobs = jobsQuery.data ?? []
const [detail, setDetail] = useState(null) 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(() => { useEffect(() => {
const id = location.state?.openManager const id = location.state?.openManager
if (id) setDetail(managers.find((m) => m.id === id) ?? null) if (id) setDetail(managers.find((m) => m.id === id) ?? null)
}, [location.state, managers]) }, [location.state, managers])
const totalReqs = managers.reduce((s, m) => s + (m.openReqs || 0), 0)
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <div className="page-head">
<div> <div>
<h1 className="page-title">Hiring Managers</h1> <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>
</div> </div>
@ -58,39 +80,57 @@ export default function Managers() {
)} )}
{managersQuery.isError && ( {managersQuery.isError && (
<EmptyState icon="managers" title="Couldnt load hiring managers"> <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> </EmptyState>
)} )}
{managersQuery.isSuccess && managers.length === 0 && ( {managersQuery.isSuccess && total === 0 && managers.length === 0 && (
<EmptyState icon="managers" title="No hiring managers"> <EmptyState icon="managers" title="No hiring managers">
No accounts currently hold the hiring-manager role. No accounts currently hold the hiring-manager role.
</EmptyState> </EmptyState>
)} )}
{managersQuery.isSuccess && managers.length > 0 && ( {managersQuery.isSuccess && (managers.length > 0 || total > 0) && (
<div className="grid g-3"> <>
{managers.map((m) => ( <div className="grid g-3">
<div className="card" key={m.id}> {managers.map((m) => (
<div className="card-body"> <div className="card" key={m.id}>
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}> <div className="card-body">
<Avatar name={m.name} className="avatar-lg" /> <div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
<div style={{ flex: 1 }}> <Avatar name={m.name} className="avatar-lg" />
<div className="lr-title">{m.name}</div> <div style={{ flex: 1 }}>
<div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div> <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> </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>
))}
</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>
))} )}
</div> </>
)} )}
{detail && ( {detail && (

View File

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

View File

@ -7,20 +7,30 @@
useDataTable alone. The other six consumers use <DataTable/>. useDataTable alone. The other six consumers use <DataTable/>.
Sort comparator and the ellipsis pager windowing are ported verbatim. Sort comparator and the ellipsis pager windowing are ported verbatim.
Client-side sort/paginate is retained deliberately there are no paginated Page size defaults to 10 (the GET `top`/`limit` default) and is user-settable;
list endpoints to bind to yet outside /users/fetch. screens that paginate on the server pass the same value as the query param.
============================================================ */ ============================================================ */
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import Icon from './icons' import Icon from './icons'
import { EmptyState } from './primitives' 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 [sort, setSort] = useState({ key: null, dir: 1 })
const [page, setPage] = useState(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). // The prototype reset to page 1 inside its imperative update(rows).
useEffect(() => setPage(1), [rows]) useEffect(() => setPage(1), [rows, size])
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (!sort.key) return rows if (!sort.key) return rows
@ -39,23 +49,23 @@ export function useDataTable({ columns, rows, pageSize = 10 }) {
}, [rows, columns, sort]) }, [rows, columns, sort])
const total = sorted.length 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 current = Math.min(page, pages)
const start = (current - 1) * pageSize const start = (current - 1) * size
function toggleSort(key) { function toggleSort(key) {
setSort((s) => (s.key === key ? { key, dir: s.dir * -1 } : { key, dir: 1 })) setSort((s) => (s.key === key ? { key, dir: s.dir * -1 } : { key, dir: 1 }))
} }
return { return {
pageRows: sorted.slice(start, start + pageSize), pageRows: sorted.slice(start, start + size),
sort, sort,
toggleSort, toggleSort,
page: current, page: current,
pages, pages,
setPage, setPage,
from: total ? start + 1 : 0, from: total ? start + 1 : 0,
to: Math.min(start + pageSize, total), to: Math.min(start + size, total),
total, total,
pageButtons: pageWindow(current, pages), pageButtons: pageWindow(current, pages),
} }
@ -71,13 +81,61 @@ export function pageWindow(cur, pages) {
return list 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 ( return (
<div className="pagination"> <div className="pagination">
<span className="page-info"> <span className="page-info">
Showing <b>{from}{to}</b> of <b>{total}</b> Showing <b>{from}{to}</b> of <b>{total}</b>
</span> </span>
<div className="page-controls"> <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"> <button className="page-btn" disabled={page === 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<Icon name="chevron-left" /> <Icon name="chevron-left" />
</button> </button>
@ -103,8 +161,9 @@ export function Pagination({ from, to, total, page, pages, setPage, pageButtons
) )
} }
export default function DataTable({ columns, rows, pageSize = 10, empty }) { export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE, pageSizeMax = 100, empty }) {
const t = useDataTable({ columns, rows, pageSize }) const [size, setSize] = useState(pageSize)
const t = useDataTable({ columns, rows, pageSize: size })
return ( return (
<div className="dt"> <div className="dt">
@ -157,7 +216,7 @@ export default function DataTable({ columns, rows, pageSize = 10, empty }) {
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination {...t} /> <Pagination {...t} pageSize={size} onPageSizeChange={setSize} pageSizeMax={pageSizeMax} />
</div> </div>
) )
} }

View File

@ -21,7 +21,7 @@ export default defineConfig({
// Same-origin style for local Vite when VITE_API_BASE is empty. // Same-origin style for local Vite when VITE_API_BASE is empty.
// Requires API published on the host (docker-compose.host-ports.yml). // Requires API published on the host (docker-compose.host-ports.yml).
proxy: { 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', target: 'http://127.0.0.1:8000',
changeOrigin: true, changeOrigin: true,
}, },