Hiring manager
parent
c03a640c96
commit
b5d2b05a6d
|
|
@ -42,3 +42,5 @@ tools/
|
|||
*.log
|
||||
tmp/
|
||||
temp/
|
||||
tests/**
|
||||
/backend/tests/**
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
@ -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"
|
||||
}}
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(...),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Pagination, useDataTable } from '../ui/DataTable'
|
||||
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import CandidateProfile from './CandidateProfile'
|
||||
|
|
@ -33,10 +33,10 @@ const EMPTY_FILTERS = { account: '' }
|
|||
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
|
||||
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
||||
|
||||
/** The seeded `candidate` role (backend/role/models.py::EnumRoles). */
|
||||
/** Seeded `candidate` role is id 8; id 4 is hiring_manager (signup default). */
|
||||
const CANDIDATE_ROLE_ID = 8
|
||||
|
||||
/* Rows are candidate USER accounts (GET /candidate/fetch/users?role_id=4), not
|
||||
/* Rows are candidate USER accounts (GET /candidate/fetch/users?role_id=8), not
|
||||
rows of the scored `candidates` table.
|
||||
|
||||
Why: /candidate/scored/fetch only ever returns CVs that have been through the
|
||||
|
|
@ -47,10 +47,10 @@ const CANDIDATE_ROLE_ID = 8
|
|||
The consequence is that the ATS columns have no source on this screen — see
|
||||
toCandidateUserView. Open a candidate to get their score, which the shared
|
||||
Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */
|
||||
async function fetchCandidates() {
|
||||
async function fetchCandidates({ top = DEFAULT_PAGE_SIZE, skip = 0 } = {}) {
|
||||
const [usersRes, appsRes] = await Promise.all([
|
||||
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID }),
|
||||
candidatesApi.list({ limit: 500 }).catch(() => null),
|
||||
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip }),
|
||||
candidatesApi.list({ limit: 100 }).catch(() => null),
|
||||
])
|
||||
const rows = Array.isArray(usersRes?.data) ? usersRes.data : []
|
||||
const sourceByUser = new Map()
|
||||
|
|
@ -112,7 +112,29 @@ export default function Candidates() {
|
|||
const navigate = useNavigate()
|
||||
const updateCandidates = useSeedMutation('candidates')
|
||||
|
||||
const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates })
|
||||
const [q, setQ] = useState('')
|
||||
const [filters, setFilters] = useState(EMPTY_FILTERS)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [sortMode, setSortMode] = useState('recent')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [profileFor, setProfileFor] = useState(null)
|
||||
const [atsFor, setAtsFor] = useState(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const skip = (page - 1) * pageSize
|
||||
const countQuery = useQuery({
|
||||
queryKey: qk.candidates.count({ roleId: CANDIDATE_ROLE_ID }),
|
||||
queryFn: async () => {
|
||||
const res = await candidatesApi.countCandidateUsers({ roleId: CANDIDATE_ROLE_ID })
|
||||
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
|
||||
},
|
||||
staleTime: Infinity,
|
||||
})
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: qk.candidates.list({ top: pageSize, skip }),
|
||||
queryFn: () => fetchCandidates({ top: pageSize, skip }),
|
||||
})
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
|
||||
const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data])
|
||||
const jobsById = useMemo(
|
||||
|
|
@ -126,14 +148,6 @@ export default function Candidates() {
|
|||
gcTime: Infinity,
|
||||
})
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [filters, setFilters] = useState(EMPTY_FILTERS)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [sortMode, setSortMode] = useState('recent')
|
||||
const [profileFor, setProfileFor] = useState(null)
|
||||
const [atsFor, setAtsFor] = useState(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const jobTitleOf = useCallback(
|
||||
(c) => jobsById[c.jobId]?.title ?? '—',
|
||||
[jobsById],
|
||||
|
|
@ -214,7 +228,14 @@ export default function Candidates() {
|
|||
[],
|
||||
)
|
||||
|
||||
const t = useDataTable({ columns, rows, pageSize: 10 })
|
||||
const t = useDataTable({ columns, rows, pageSize: Math.max(rows.length, 1) })
|
||||
const total = countQuery.data ?? 0
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(page, pages)
|
||||
|
||||
useEffect(() => {
|
||||
if (page > pages) setPage(pages)
|
||||
}, [page, pages])
|
||||
|
||||
const recentChips = recentlyViewed
|
||||
.slice(0, 6)
|
||||
|
|
@ -280,7 +301,7 @@ export default function Candidates() {
|
|||
<div>
|
||||
<h1 className="page-title">Candidates</h1>
|
||||
<p className="page-sub">
|
||||
{rows.length} candidate account{rows.length === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}
|
||||
{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
|
|
@ -428,7 +449,18 @@ export default function Candidates() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination {...t} />
|
||||
<Pagination
|
||||
from={total ? (currentPage - 1) * pageSize + 1 : 0}
|
||||
to={total ? (currentPage - 1) * pageSize + rows.length : 0}
|
||||
total={total}
|
||||
page={currentPage}
|
||||
pages={pages}
|
||||
setPage={setPage}
|
||||
pageButtons={pageWindow(currentPage, pages)}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={(n) => { setPageSize(n); setPage(1) }}
|
||||
pageSizeMax={500}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
/* ============================================================
|
||||
Recruitment Inbox — application tabs over GET /inbox/all-applications.
|
||||
Page size is 10, newest first (order by created_at on the server).
|
||||
Page size defaults to 10. Pagination (skip/offset) is independent of the
|
||||
limit control except that page 2 uses the current limit: skip = (page-1)*limit.
|
||||
Total comes from a count endpoint called once when the page opens.
|
||||
============================================================ */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
|
@ -10,7 +12,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||
import Modal from '../ui/Modal'
|
||||
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Pagination, pageWindow } from '../ui/DataTable'
|
||||
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
|
||||
import { useToast } from '../ui/Toast'
|
||||
|
|
@ -29,7 +31,8 @@ import {
|
|||
const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates']
|
||||
/** Sheet Forms have no mailbox read state — no Unread tab on that channel. */
|
||||
const FORM_TABS = ['All Applications', 'Processed', 'Rejected', 'Duplicates']
|
||||
const PAGE_SIZE = 10
|
||||
/** Inbox GET `top` / sheet GET `limit` both cap at 500. */
|
||||
const PAGE_SIZE_MAX = 500
|
||||
|
||||
/** Inbox channel: Outlook email queue vs imported Google Form rows. */
|
||||
const CHANNELS = [
|
||||
|
|
@ -676,6 +679,7 @@ export default function Inbox() {
|
|||
const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET)
|
||||
const [tab, setTab] = useState('All Applications')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [q, setQ] = useState('')
|
||||
const [assigning, setAssigning] = useState(null)
|
||||
|
|
@ -688,18 +692,18 @@ export default function Inbox() {
|
|||
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
|
||||
const listParams = useMemo(() => ({
|
||||
...tabFilter,
|
||||
top: PAGE_SIZE,
|
||||
skip: (page - 1) * PAGE_SIZE,
|
||||
top: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
...(q.trim() ? { search: q.trim() } : {}),
|
||||
}), [tabFilter, page, q])
|
||||
}), [tabFilter, page, pageSize, q])
|
||||
|
||||
const formParams = useMemo(() => ({
|
||||
sheet: formSheet || undefined,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
...formTabFilter,
|
||||
...(q.trim() ? { search: q.trim() } : {}),
|
||||
}), [formSheet, page, q, formTabFilter])
|
||||
}), [formSheet, page, pageSize, q, formTabFilter])
|
||||
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: qk.mailbox.applications(listParams),
|
||||
|
|
@ -738,6 +742,26 @@ export default function Inbox() {
|
|||
enabled: isForms,
|
||||
})
|
||||
|
||||
const emailTotalQuery = useQuery({
|
||||
queryKey: qk.mailbox.applicationTotal(),
|
||||
queryFn: async () => {
|
||||
const res = await inboxApi.countApplications()
|
||||
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
|
||||
},
|
||||
enabled: !isForms,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const formTotalQuery = useQuery({
|
||||
queryKey: qk.mailbox.formTotal({ sheet: formSheet || undefined }),
|
||||
queryFn: async () => {
|
||||
const res = await sheetApi.countFormData({ sheet: formSheet || undefined })
|
||||
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
|
||||
},
|
||||
enabled: isForms,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
// Prefer the imported sheet list; keep the known 2026 tab even when the
|
||||
// sheets endpoint is still loading so the first paint is not blank.
|
||||
const formSheetOptions = useMemo(() => {
|
||||
|
|
@ -755,9 +779,6 @@ export default function Inbox() {
|
|||
|
||||
const activeQuery = isForms ? formQuery : applicationsQuery
|
||||
const inbox = activeQuery.data?.rows ?? []
|
||||
const total = activeQuery.data?.total ?? 0
|
||||
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
const currentPage = Math.min(page, pages)
|
||||
const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {})
|
||||
|
||||
const counts = useMemo(
|
||||
|
|
@ -771,6 +792,15 @@ export default function Inbox() {
|
|||
[serverCounts],
|
||||
)
|
||||
|
||||
const poolTotal = isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0)
|
||||
const tabTotal = counts[tab] ?? 0
|
||||
const countsReady = isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess
|
||||
const total = q.trim()
|
||||
? (activeQuery.data?.total ?? 0)
|
||||
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(page, pages)
|
||||
|
||||
const list = inbox
|
||||
|
||||
const detailQuery = useQuery({
|
||||
|
|
@ -1090,13 +1120,21 @@ export default function Inbox() {
|
|||
</div>
|
||||
{activeQuery.isSuccess && total > 0 && (
|
||||
<Pagination
|
||||
from={total ? (currentPage - 1) * PAGE_SIZE + 1 : 0}
|
||||
to={Math.min(currentPage * PAGE_SIZE, total)}
|
||||
from={total ? (currentPage - 1) * pageSize + 1 : 0}
|
||||
to={Math.min(currentPage * pageSize, total)}
|
||||
total={total}
|
||||
page={currentPage}
|
||||
pages={pages}
|
||||
setPage={(p) => { setPage(p); setSelectedId(null); selection.clear() }}
|
||||
pageButtons={pageWindow(currentPage, pages)}
|
||||
pageSize={pageSize}
|
||||
pageSizeMax={PAGE_SIZE_MAX}
|
||||
onPageSizeChange={(n) => {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
setSelectedId(null)
|
||||
selection.clear()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { DEFAULT_PAGE_SIZE, PageSizeField } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, Icon, ProgressBar } from '../ui/primitives'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
|
|
@ -21,6 +22,8 @@ import * as candidatesApi from '../api/candidates'
|
|||
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
|
||||
|
||||
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
|
||||
/** Backend GET /candidate/scored/fetch caps `limit` at 100. */
|
||||
const PAGE_SIZE_MAX = 100
|
||||
|
||||
/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
|
||||
function displayName(name) {
|
||||
|
|
@ -246,12 +249,13 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
|
|||
export default function JobCandidates({ jobId, jobTitle }) {
|
||||
const [q, setQ] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [viewing, setViewing] = useState(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.candidates.list({ jobId }),
|
||||
queryKey: qk.candidates.list({ jobId, limit: pageSize }),
|
||||
queryFn: async () => {
|
||||
const res = await candidatesApi.listCandidates({ jobId })
|
||||
const res = await candidatesApi.listCandidates({ jobId, limit: pageSize })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(candidatesApi.toCandidateView)
|
||||
},
|
||||
|
|
@ -303,6 +307,12 @@ export default function JobCandidates({ jobId, jobTitle }) {
|
|||
<option value="completed">Scored</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
<PageSizeField
|
||||
value={pageSize}
|
||||
onChange={setPageSize}
|
||||
max={PAGE_SIZE_MAX}
|
||||
label="Show"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
|
|
@ -12,10 +13,17 @@ import * as usersApi from '../api/users'
|
|||
import * as jobsApi from '../api/jobs'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
|
||||
async function fetchManagers() {
|
||||
const res = await usersApi.listManagers()
|
||||
const PAGE_SIZE_MAX = 500
|
||||
/** Seeded `hiring_manager` role (backend/role/models.py::EnumRoles). */
|
||||
const HIRING_MANAGER_ROLE_ID = 4
|
||||
|
||||
async function fetchManagers({ top = DEFAULT_PAGE_SIZE, skip = 0 } = {}) {
|
||||
const res = await usersApi.list({ roleId: HIRING_MANAGER_ROLE_ID, top, skip })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(usersApi.toManagerView)
|
||||
return {
|
||||
rows: rows.map(usersApi.toManagerView),
|
||||
total: typeof res?.total === 'number' ? res.total : 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJobs() {
|
||||
|
|
@ -31,25 +39,39 @@ export default function Managers() {
|
|||
const { can } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const managersQuery = useQuery({ queryKey: qk.managers.list(), queryFn: fetchManagers })
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
|
||||
const managers = managersQuery.data ?? []
|
||||
const jobs = jobsQuery.data ?? []
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [detail, setDetail] = useState(null)
|
||||
|
||||
const skip = (page - 1) * pageSize
|
||||
const managersQuery = useQuery({
|
||||
queryKey: qk.managers.list({ roleId: HIRING_MANAGER_ROLE_ID, top: pageSize, skip }),
|
||||
queryFn: () => fetchManagers({ top: pageSize, skip }),
|
||||
})
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
|
||||
const managers = managersQuery.data?.rows ?? []
|
||||
const total = managersQuery.data?.total ?? 0
|
||||
const jobs = jobsQuery.data ?? []
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(page, pages)
|
||||
|
||||
useEffect(() => {
|
||||
if (page > pages) setPage(pages)
|
||||
}, [page, pages])
|
||||
|
||||
useEffect(() => {
|
||||
const id = location.state?.openManager
|
||||
if (id) setDetail(managers.find((m) => m.id === id) ?? null)
|
||||
}, [location.state, managers])
|
||||
|
||||
const totalReqs = managers.reduce((s, m) => s + (m.openReqs || 0), 0)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Hiring Managers</h1>
|
||||
<p className="page-sub">{managers.length} managers · {totalReqs} active requisitions</p>
|
||||
<p className="page-sub">
|
||||
{total} manager{total === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -58,39 +80,57 @@ export default function Managers() {
|
|||
)}
|
||||
{managersQuery.isError && (
|
||||
<EmptyState icon="managers" title="Couldn’t load hiring managers">
|
||||
{friendlyAuthError(managersQuery.error, 'This directory needs jobs.view or candidates.view.')}
|
||||
{friendlyAuthError(managersQuery.error, 'This directory needs rbac_users.view.')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{managersQuery.isSuccess && managers.length === 0 && (
|
||||
{managersQuery.isSuccess && total === 0 && managers.length === 0 && (
|
||||
<EmptyState icon="managers" title="No hiring managers">
|
||||
No accounts currently hold the hiring-manager role.
|
||||
</EmptyState>
|
||||
)}
|
||||
{managersQuery.isSuccess && managers.length > 0 && (
|
||||
<div className="grid g-3">
|
||||
{managers.map((m) => (
|
||||
<div className="card" key={m.id}>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
|
||||
<Avatar name={m.name} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="lr-title">{m.name}</div>
|
||||
<div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div>
|
||||
{managersQuery.isSuccess && (managers.length > 0 || total > 0) && (
|
||||
<>
|
||||
<div className="grid g-3">
|
||||
{managers.map((m) => (
|
||||
<div className="card" key={m.id}>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
|
||||
<Avatar name={m.name} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="lr-title">{m.name}</div>
|
||||
<div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
|
||||
</div>
|
||||
<div className="divider" style={{ margin: '12px 0' }} />
|
||||
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="cell-sub"><Icon name="mail" /> {m.email ? m.email.split('@')[0] : '—'}</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
|
||||
</div>
|
||||
<div className="divider" style={{ margin: '12px 0' }} />
|
||||
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="cell-sub"><Icon name="mail" /> {m.email ? m.email.split('@')[0] : '—'}</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<Pagination
|
||||
from={total ? (currentPage - 1) * pageSize + 1 : 0}
|
||||
to={total ? (currentPage - 1) * pageSize + managers.length : 0}
|
||||
total={total}
|
||||
page={currentPage}
|
||||
pages={pages}
|
||||
setPage={setPage}
|
||||
pageButtons={pageWindow(currentPage, pages)}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={(n) => { setPageSize(n); setPage(1) }}
|
||||
pageSizeMax={PAGE_SIZE_MAX}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { useMemo, useState } from 'react'
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { DEFAULT_PAGE_SIZE, PageSizeField } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import CandidateProfile from './CandidateProfile'
|
||||
|
|
@ -45,8 +46,8 @@ import * as candidatesApi from '../api/candidates'
|
|||
import * as pipelineApi from '../api/pipeline'
|
||||
import { avatarColor, departments, initials as initialsOf } from '../data/seed'
|
||||
|
||||
/** The seed bucket holds 100 candidates; one template per person, no reuse. */
|
||||
const FETCH_LIMIT = 100
|
||||
/** Backend GET /candidate/fetch caps `limit` at 100. */
|
||||
const PAGE_SIZE_MAX = 100
|
||||
|
||||
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
||||
|
||||
|
|
@ -127,6 +128,7 @@ export default function TalentPool() {
|
|||
|
||||
const [q, setQ] = useState('')
|
||||
const [dept, setDept] = useState('')
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [profileFor, setProfileFor] = useState(null)
|
||||
const [atsFor, setAtsFor] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -139,8 +141,8 @@ export default function TalentPool() {
|
|||
}
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.candidates.list({ limit: FETCH_LIMIT }),
|
||||
queryFn: () => candidatesApi.list({ limit: FETCH_LIMIT }),
|
||||
queryKey: qk.candidates.list({ limit: pageSize }),
|
||||
queryFn: () => candidatesApi.list({ limit: pageSize }),
|
||||
})
|
||||
|
||||
const pool = useMemo(
|
||||
|
|
@ -227,6 +229,12 @@ export default function TalentPool() {
|
|||
<option value="">All Departments</option>
|
||||
{departments.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
<PageSizeField
|
||||
value={pageSize}
|
||||
onChange={setPageSize}
|
||||
max={PAGE_SIZE_MAX}
|
||||
label="Show"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -7,20 +7,30 @@
|
|||
useDataTable alone. The other six consumers use <DataTable/>.
|
||||
|
||||
Sort comparator and the ellipsis pager windowing are ported verbatim.
|
||||
Client-side sort/paginate is retained deliberately — there are no paginated
|
||||
list endpoints to bind to yet outside /users/fetch.
|
||||
Page size defaults to 10 (the GET `top`/`limit` default) and is user-settable;
|
||||
screens that paginate on the server pass the same value as the query param.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Icon from './icons'
|
||||
import { EmptyState } from './primitives'
|
||||
|
||||
export function useDataTable({ columns, rows, pageSize = 10 }) {
|
||||
/** Matches the backend Query(10) default on list GET endpoints. */
|
||||
export const DEFAULT_PAGE_SIZE = 10
|
||||
|
||||
export function clampPageSize(value, max = 100) {
|
||||
const n = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(n) || n < 1) return DEFAULT_PAGE_SIZE
|
||||
return Math.min(n, max)
|
||||
}
|
||||
|
||||
export function useDataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE }) {
|
||||
const [sort, setSort] = useState({ key: null, dir: 1 })
|
||||
const [page, setPage] = useState(1)
|
||||
const size = Math.max(1, pageSize || DEFAULT_PAGE_SIZE)
|
||||
|
||||
// The prototype reset to page 1 inside its imperative update(rows).
|
||||
useEffect(() => setPage(1), [rows])
|
||||
useEffect(() => setPage(1), [rows, size])
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sort.key) return rows
|
||||
|
|
@ -39,23 +49,23 @@ export function useDataTable({ columns, rows, pageSize = 10 }) {
|
|||
}, [rows, columns, sort])
|
||||
|
||||
const total = sorted.length
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const pages = Math.max(1, Math.ceil(total / size))
|
||||
const current = Math.min(page, pages)
|
||||
const start = (current - 1) * pageSize
|
||||
const start = (current - 1) * size
|
||||
|
||||
function toggleSort(key) {
|
||||
setSort((s) => (s.key === key ? { key, dir: s.dir * -1 } : { key, dir: 1 }))
|
||||
}
|
||||
|
||||
return {
|
||||
pageRows: sorted.slice(start, start + pageSize),
|
||||
pageRows: sorted.slice(start, start + size),
|
||||
sort,
|
||||
toggleSort,
|
||||
page: current,
|
||||
pages,
|
||||
setPage,
|
||||
from: total ? start + 1 : 0,
|
||||
to: Math.min(start + pageSize, total),
|
||||
to: Math.min(start + size, total),
|
||||
total,
|
||||
pageButtons: pageWindow(current, pages),
|
||||
}
|
||||
|
|
@ -71,13 +81,61 @@ export function pageWindow(cur, pages) {
|
|||
return list
|
||||
}
|
||||
|
||||
export function Pagination({ from, to, total, page, pages, setPage, pageButtons }) {
|
||||
/** Local draft so typing "50" does not fire a GET for 5, then 50. */
|
||||
export function PageSizeField({ value, onChange, max = 100, label = 'Per page', id }) {
|
||||
const [draft, setDraft] = useState(String(value ?? DEFAULT_PAGE_SIZE))
|
||||
useEffect(() => setDraft(String(value ?? DEFAULT_PAGE_SIZE)), [value])
|
||||
|
||||
function commit() {
|
||||
const next = clampPageSize(draft, max)
|
||||
setDraft(String(next))
|
||||
if (next !== value) onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="page-size">
|
||||
<span className="page-size-label">{label}</span>
|
||||
<input
|
||||
id={id}
|
||||
className="page-size-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={max}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
aria-label={label}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
from, to, total, page, pages, setPage, pageButtons,
|
||||
pageSize, onPageSizeChange, pageSizeMax = 100,
|
||||
}) {
|
||||
return (
|
||||
<div className="pagination">
|
||||
<span className="page-info">
|
||||
Showing <b>{from}–{to}</b> of <b>{total}</b>
|
||||
</span>
|
||||
<div className="page-controls">
|
||||
{onPageSizeChange && (
|
||||
<>
|
||||
<PageSizeField
|
||||
value={pageSize ?? DEFAULT_PAGE_SIZE}
|
||||
onChange={onPageSizeChange}
|
||||
max={pageSizeMax}
|
||||
/>
|
||||
<span className="page-size-total">of <b>{total}</b></span>
|
||||
</>
|
||||
)}
|
||||
<button className="page-btn" disabled={page === 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
|
||||
<Icon name="chevron-left" />
|
||||
</button>
|
||||
|
|
@ -103,8 +161,9 @@ export function Pagination({ from, to, total, page, pages, setPage, pageButtons
|
|||
)
|
||||
}
|
||||
|
||||
export default function DataTable({ columns, rows, pageSize = 10, empty }) {
|
||||
const t = useDataTable({ columns, rows, pageSize })
|
||||
export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE, pageSizeMax = 100, empty }) {
|
||||
const [size, setSize] = useState(pageSize)
|
||||
const t = useDataTable({ columns, rows, pageSize: size })
|
||||
|
||||
return (
|
||||
<div className="dt">
|
||||
|
|
@ -157,7 +216,7 @@ export default function DataTable({ columns, rows, pageSize = 10, empty }) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination {...t} />
|
||||
<Pagination {...t} pageSize={size} onPageSizeChange={setSize} pageSizeMax={pageSizeMax} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue