corrected candiadte
parent
4a33651082
commit
2efc2cdf3a
|
|
@ -101,15 +101,12 @@ 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:
|
||||
from employment_agent.plugins import _phone_digits,_phone_score,phone_in_resume
|
||||
digits=_phone_digits(text)
|
||||
if _phone_score(digits)<0:
|
||||
return None
|
||||
if (resume_text or "").strip() and not phone_in_resume(digits,resume_text):
|
||||
return None
|
||||
if (resume_text or "").strip():
|
||||
haystack=re.sub(r"\D","",resume_text)
|
||||
if digits not in haystack:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ Call like the rest of the backend:
|
|||
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.
|
||||
`scan_phone` is the digit-span scan `prefer_extracted_phone` uses so the
|
||||
stacked parser cannot recurse into itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -23,16 +23,43 @@ from employment_agent.decorators import (
|
|||
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"
|
||||
)
|
||||
# PDF extraction uses en/em dashes, nbsp, and bullets as digit separators.
|
||||
_DASH_TO_HYPHEN=str.maketrans({
|
||||
"\u2010":"-","\u2011":"-","\u2012":"-","\u2013":"-","\u2014":"-",
|
||||
"\u2015":"-","\u2212":"-","\u2043":"-","\uFE58":"-","\uFE63":"-",
|
||||
"\uFF0D":"-",
|
||||
})
|
||||
_STRIP_INVISIBLE="".join((
|
||||
"\u00ad","\u200b","\u200c","\u200d","\u2060","\ufeff",
|
||||
))
|
||||
_DIGIT_TO_ASCII=str.maketrans({
|
||||
**{chr(0x0660+i):str(i) for i in range(10)},
|
||||
**{chr(0x06F0+i):str(i) for i in range(10)},
|
||||
**{chr(0xFF10+i):str(i) for i in range(10)},
|
||||
})
|
||||
_OCR_O=re.compile(r"(?<![A-Za-z0-9])[Oo](?=3\d{2}[\s\-.\d]{6,})")
|
||||
_DIGIT_GROUP=re.compile(r"\+?\d+")
|
||||
_GAP_OK=re.compile(r"^[\s\-./()[\]{},:|•·∙+_]*$")
|
||||
_WA_ME=re.compile(r"(?i)(?:wa\.me/|api\.whatsapp\.com/send\?phone=)(\+?\d{10,15})")
|
||||
_TEL_URI=re.compile(r"(?i)tel:\s*(\+?[\d\s\-().]{8,22})")
|
||||
_YEAR=re.compile(r"^(?:19|20)\d{2}$")
|
||||
|
||||
|
||||
def _normalize_phone_text(text:str) -> str:
|
||||
raw=(text or "").translate(_DIGIT_TO_ASCII).translate(_DASH_TO_HYPHEN)
|
||||
raw=raw.replace("\xa0"," ").replace("\u202f"," ").replace("\u2009"," ")
|
||||
raw=raw.replace("\u2007"," ").replace("\u2028","\n").replace("\u2029","\n")
|
||||
for ch in _STRIP_INVISIBLE:
|
||||
raw=raw.replace(ch,"")
|
||||
return _OCR_O.sub("0",raw)
|
||||
|
||||
|
||||
def _digits_only(raw:str) -> str:
|
||||
return re.sub(r"\D","",_normalize_phone_text(raw or ""))
|
||||
|
||||
|
||||
def _phone_digits(raw:str) -> str:
|
||||
digits=re.sub(r"\D","",raw or "")
|
||||
digits=_digits_only(raw)
|
||||
if digits.startswith("00"):
|
||||
digits=digits[2:]
|
||||
return digits
|
||||
|
|
@ -54,37 +81,90 @@ def _phone_score(digits:str) -> int:
|
|||
return n
|
||||
|
||||
|
||||
def _phone_keys(digits:str) -> set[str]:
|
||||
"""03XX / +92 3XX / 3XX national forms of the same PK mobile."""
|
||||
d=_phone_digits(digits) if re.search(r"\D",digits or "") else (digits or "")
|
||||
if d.startswith("00"):
|
||||
d=d[2:]
|
||||
keys={d}
|
||||
if d.startswith("92") and len(d)>=12:
|
||||
rest=d[2:]
|
||||
keys.add(rest)
|
||||
if rest.startswith("3"):
|
||||
keys.add("0"+rest)
|
||||
if d.startswith("0") and len(d)>=11:
|
||||
keys.add(d[1:])
|
||||
keys.add("92"+d[1:])
|
||||
if d.startswith("3") and len(d)==10:
|
||||
keys.add("0"+d)
|
||||
keys.add("92"+d)
|
||||
return {k for k in keys if len(k)>=10}
|
||||
|
||||
|
||||
def phone_in_resume(digits:str,resume_text:str) -> bool:
|
||||
"""True when this number (or its 03 / +92 twin) appears in the CV digits."""
|
||||
haystack=_digits_only(resume_text)
|
||||
if not haystack:
|
||||
return True
|
||||
return any(key in haystack for key in _phone_keys(digits))
|
||||
|
||||
|
||||
def _tidy_raw(raw:str) -> str:
|
||||
compact=re.sub(r"[\n\r]+"," ",raw or "")
|
||||
compact=re.sub(r"[ \t]+"," ",compact)
|
||||
return compact.strip(" \t-./()[]{},:|•·∙_")
|
||||
|
||||
|
||||
def _consider(raw:str,best:str|None,best_score:int) -> tuple[str|None,int]:
|
||||
value=_tidy_raw(raw)
|
||||
score=_phone_score(_phone_digits(value))
|
||||
if score>best_score:
|
||||
return value,score
|
||||
return best,best_score
|
||||
|
||||
|
||||
def _scan_digit_groups(text:str,best:str|None,best_score:int) -> tuple[str|None,int]:
|
||||
groups=list(_DIGIT_GROUP.finditer(text))
|
||||
for i,start_g in enumerate(groups):
|
||||
acc=start_g.group(0)
|
||||
end=start_g.end()
|
||||
best,best_score=_consider(acc,best,best_score)
|
||||
for nxt in groups[i+1:]:
|
||||
gap=text[end:nxt.start()]
|
||||
if not _GAP_OK.match(gap):
|
||||
break
|
||||
nxt_digits=nxt.group(0).lstrip("+")
|
||||
if _YEAR.match(nxt_digits) and len(_phone_digits(acc))>=10:
|
||||
break
|
||||
combined=_phone_digits(acc+nxt.group(0))
|
||||
if len(combined)>15:
|
||||
break
|
||||
acc=text[start_g.start():nxt.end()]
|
||||
end=nxt.end()
|
||||
best,best_score=_consider(acc,best,best_score)
|
||||
return best,best_score
|
||||
|
||||
|
||||
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):
|
||||
"""Scan CV text for a complete phone — unicode separators, wrap, tel/wa.me."""
|
||||
haystack=_normalize_phone_text(text or "")
|
||||
best,best_score=None,-1
|
||||
best,best_score=_scan_digit_groups(haystack,best,best_score)
|
||||
for pattern in (_WA_ME,_TEL_URI):
|
||||
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
|
||||
best,best_score=_consider(match.group(1),best,best_score)
|
||||
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
|
||||
"""Keep the strongest complete number. Truncated / CNIC-shaped values lose."""
|
||||
best,best_score=None,-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
|
||||
best,best_score=_consider(value,best,best_score)
|
||||
return best if best_score>=0 else None
|
||||
|
||||
|
||||
def _as_str(data,key):
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ 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.
|
||||
- Spaces, hyphens, parentheses, en-dashes, bullets, and non-breaking spaces are allowed; do not delete trailing digits to "clean" the value.
|
||||
- A Phone / Mobile / Cell / WhatsApp / Tel label may sit on the line above the digits — still copy the number.
|
||||
- 03XX-XXXXXXX and +92 3XX XXXXXXX are the same number; return the form written on the resume.
|
||||
- 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):
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Index, and_, case, delete, func, insert, or_, update
|
||||
from sqlalchemy import Column, DateTime, Index, and_, case, delete, false, func, insert, or_, update
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
|
@ -127,6 +127,70 @@ class FormData(SQLModel, table=True):
|
|||
else_=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _suggested_contains_any(cls, job_post_ids):
|
||||
ids = [str(jid) for jid in (job_post_ids or []) if jid]
|
||||
if not ids:
|
||||
return false()
|
||||
return or_(*(cls.suggested_job_post_ids.contains([sid]) for sid in ids))
|
||||
|
||||
@classmethod
|
||||
def _has_job_link(cls):
|
||||
return or_(
|
||||
cls.assigned_job_post_id.is_not(None),
|
||||
cls.job_post_id.is_not(None),
|
||||
~cls._no_suggested_jobs(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _matches_any_job(cls, job_post_ids):
|
||||
ids = list(job_post_ids or [])
|
||||
if not ids:
|
||||
return false()
|
||||
return or_(
|
||||
cls.assigned_job_post_id.in_(ids),
|
||||
cls.job_post_id.in_(ids),
|
||||
cls._suggested_contains_any(ids),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _talent_pool_filters(cls, *, search=None, job_post_ids=None):
|
||||
"""Same WHERE as list_for_talent_pool / count_for_talent_pool."""
|
||||
filters = [cls.manual_upload_candidate_id.is_(None)]
|
||||
if job_post_ids is not None:
|
||||
filters.append(cls._matches_any_job(list(job_post_ids)))
|
||||
else:
|
||||
filters.append(cls._has_job_link())
|
||||
if search:
|
||||
like = f"%{search.strip()}%"
|
||||
filters.append(or_(cls.name.ilike(like), cls.candidate_email.ilike(like)))
|
||||
return filters
|
||||
|
||||
@classmethod
|
||||
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
|
||||
"""Candidates list: unpromoted form rows with assigned or suggested jobs."""
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
return []
|
||||
qry = (
|
||||
select(cls)
|
||||
.where(*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids))
|
||||
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await session.execute(qry)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None):
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
return 0
|
||||
qry = select(func.count()).select_from(cls).where(
|
||||
*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids)
|
||||
)
|
||||
result = await session.execute(qry)
|
||||
return result.scalar_one()
|
||||
|
||||
@staticmethod
|
||||
def _cities_match(column, cities):
|
||||
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""
|
||||
|
|
|
|||
|
|
@ -365,6 +365,20 @@ class Inbox(SQLModel, table=True):
|
|||
pattern = f"%{search}%"
|
||||
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
|
||||
|
||||
@classmethod
|
||||
def _with_job_link(cls, qry, job_post_ids):
|
||||
"""List mode: keep only applications with an assigned post or suggestions.
|
||||
|
||||
`job_post_ids is None` is the unscoped (admin) list — still require a
|
||||
link so unassigned inbox mail never appears on Candidates. A UUID list
|
||||
is assigned IN those ids OR suggested_job_post_ids containing any of
|
||||
them (the `assigned_job_post_id` query param is that one-element list).
|
||||
"""
|
||||
qry = qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
if job_post_ids is not None:
|
||||
return qry.where(Inbox_Messages._matches_any_job(list(job_post_ids)))
|
||||
return qry.where(Inbox_Messages._has_job_link())
|
||||
|
||||
@classmethod
|
||||
async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None,job_post_ids=None):
|
||||
try:
|
||||
|
|
@ -388,11 +402,8 @@ class Inbox(SQLModel, table=True):
|
|||
qry = qry.where(cls.user_id == user_id)
|
||||
if search:
|
||||
qry = qry.where(cls._candidate_search_filter(search))
|
||||
if job_post_ids is not None:
|
||||
qry = (
|
||||
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
|
||||
)
|
||||
if not user_id:
|
||||
qry = cls._with_job_link(qry, job_post_ids)
|
||||
# Most-recent-first is the list contract; id breaks ties so a page
|
||||
# boundary can't drop or repeat a row when created_at collides.
|
||||
qry = qry.order_by(cls.created_at.desc(), cls.id.desc())
|
||||
|
|
@ -422,11 +433,8 @@ class Inbox(SQLModel, table=True):
|
|||
qry = qry.where(cls.user_id == user_id)
|
||||
if search:
|
||||
qry = qry.where(cls._candidate_search_filter(search))
|
||||
if job_post_ids is not None:
|
||||
qry = (
|
||||
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
|
||||
)
|
||||
if not user_id:
|
||||
qry = cls._with_job_link(qry, job_post_ids)
|
||||
result = await session.execute(qry)
|
||||
return result.scalar_one()
|
||||
except Exception as e:
|
||||
|
|
@ -1026,6 +1034,28 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
else_=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _suggested_contains_any(cls, job_post_ids):
|
||||
"""JSONB @> '["uuid"]' for any id — suggested_job_post_ids stores strings."""
|
||||
ids = [str(jid) for jid in (job_post_ids or []) if jid]
|
||||
if not ids:
|
||||
return false()
|
||||
return or_(*(cls.suggested_job_post_ids.contains([sid]) for sid in ids))
|
||||
|
||||
@classmethod
|
||||
def _has_job_link(cls):
|
||||
return or_(cls.assigned_job_post_id.is_not(None), ~cls._no_suggested_jobs())
|
||||
|
||||
@classmethod
|
||||
def _matches_any_job(cls, job_post_ids):
|
||||
ids = list(job_post_ids or [])
|
||||
if not ids:
|
||||
return false()
|
||||
return or_(
|
||||
cls.assigned_job_post_id.in_(ids),
|
||||
cls._suggested_contains_any(ids),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cities_match(column, cities):
|
||||
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""
|
||||
|
|
|
|||
|
|
@ -485,23 +485,13 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
return updated
|
||||
|
||||
@classmethod
|
||||
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
|
||||
"""Newest applications with a user + job for Talent Pool (manual / form)."""
|
||||
from users.models import Users
|
||||
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
return []
|
||||
statement = (
|
||||
select(cls)
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.where(cls.user_id.is_not(None), cls.job_post_id.is_not(None))
|
||||
.order_by(cls.created_at.desc())
|
||||
)
|
||||
def _talent_pool_filters(cls, Users, *, search=None, job_post_ids=None):
|
||||
filters = [cls.user_id.is_not(None), cls.job_post_id.is_not(None)]
|
||||
if job_post_ids is not None:
|
||||
statement = statement.where(cls.job_post_id.in_(list(job_post_ids)))
|
||||
filters.append(cls.job_post_id.in_(list(job_post_ids)))
|
||||
if search:
|
||||
like = f"%{search.strip()}%"
|
||||
statement = statement.where(
|
||||
filters.append(
|
||||
or_(
|
||||
cls.candidate_name.ilike(like),
|
||||
cls.candidate_email.ilike(like),
|
||||
|
|
@ -509,10 +499,41 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
Users.email.ilike(like),
|
||||
)
|
||||
)
|
||||
statement = statement.limit(limit).offset(offset)
|
||||
return filters
|
||||
|
||||
@classmethod
|
||||
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
|
||||
"""Newest applications with a user + assigned job for Candidates / Talent Pool."""
|
||||
from users.models import Users
|
||||
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
return []
|
||||
statement = (
|
||||
select(cls)
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.where(*cls._talent_pool_filters(Users, search=search, job_post_ids=job_post_ids))
|
||||
.order_by(cls.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None):
|
||||
from users.models import Users
|
||||
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
return 0
|
||||
statement = (
|
||||
select(func.count())
|
||||
.select_from(cls)
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.where(*cls._talent_pool_filters(Users, search=search, job_post_ids=job_post_ids))
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
||||
@classmethod
|
||||
async def sources_by_user_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
||||
"""Newest platform/apply_via label per user — Candidates Form badges."""
|
||||
|
|
|
|||
|
|
@ -313,6 +313,79 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def serialize_manual_candidate_list(profile: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List shape of serialize_manual_candidate_profile — drop heavy detail."""
|
||||
return {
|
||||
"inbox_id": None,
|
||||
"manual_upload_candidate_id": profile.get("manual_upload_candidate_id"),
|
||||
"user_id": profile.get("user_id"),
|
||||
"candidate_id": None,
|
||||
"name": profile.get("name"),
|
||||
"email": profile.get("email"),
|
||||
"is_active": profile.get("is_active"),
|
||||
"message_id": None,
|
||||
"created_at": profile.get("created_at"),
|
||||
"application_status": profile.get("application_status"),
|
||||
"experience": profile.get("experience"),
|
||||
"current_employment": profile.get("current_employment"),
|
||||
"current_title": profile.get("current_title"),
|
||||
"resume_text": None,
|
||||
"suggested_job_post_ids": profile.get("suggested_job_post_ids") or [],
|
||||
"assigned_job_post_id": profile.get("assigned_job_post_id"),
|
||||
"job_posts": profile.get("job_posts") or [],
|
||||
"assigned_job_post": profile.get("assigned_job_post"),
|
||||
"job_title": profile.get("job_title"),
|
||||
"recruiter": profile.get("recruiter"),
|
||||
"recruiter_id": profile.get("recruiter_id"),
|
||||
"source": profile.get("source"),
|
||||
"file_path": profile.get("file_path"),
|
||||
"ai_score": None,
|
||||
"recommendation": None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_form_candidate_list(row) -> Dict[str, Any]:
|
||||
"""GET /candidate/fetch list row for an unpromoted FormData application.
|
||||
|
||||
processing_state is an inbox tab, not Candidate_application_Status, so it
|
||||
is not copied onto application_status — CLOSED/rejected-tab values would
|
||||
paint every sheet row as Rejected on Candidates.
|
||||
"""
|
||||
assigned = row.assigned_job_post_id or row.job_post_id
|
||||
suggested = [str(v) for v in (row.suggested_job_post_ids or []) if v not in (None, "")]
|
||||
created = row.created_at.isoformat() if row.created_at else None
|
||||
name = (row.name or "").strip() or None
|
||||
email = (row.candidate_email or "").strip() or None
|
||||
return {
|
||||
"inbox_id": None,
|
||||
"form_data_id": str(row.id),
|
||||
"manual_upload_candidate_id": None,
|
||||
"user_id": None,
|
||||
"candidate_id": None,
|
||||
"name": name,
|
||||
"email": email,
|
||||
"is_active": None,
|
||||
"message_id": None,
|
||||
"created_at": created,
|
||||
"application_status": None,
|
||||
"experience": (row.experience or "").strip() or None,
|
||||
"current_employment": (row.current_company or "").strip() or None,
|
||||
"current_title": (row.position_applied_for or "").strip() or None,
|
||||
"resume_text": None,
|
||||
"suggested_job_post_ids": suggested,
|
||||
"assigned_job_post_id": str(assigned) if assigned else None,
|
||||
"job_posts": [],
|
||||
"assigned_job_post": None,
|
||||
"job_title": (row.position_applied_for or "").strip() or None,
|
||||
"recruiter": None,
|
||||
"recruiter_id": None,
|
||||
"source": "Form",
|
||||
"file_path": None,
|
||||
"ai_score": None,
|
||||
"recommendation": None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_manager_candidate(row, *, source) -> dict:
|
||||
"""One application on a hiring-manager's job — list row, not the profile."""
|
||||
inbox_id = row.get("inbox_id")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from job.candidate.plugins import (
|
|||
normalize_spaced_text,
|
||||
)
|
||||
from g_sheet.models import FormData
|
||||
from job.candidate.serializers import is_assigned_application,serialize_application_history,serialize_application_history_item,serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
|
||||
from job.candidate.serializers import is_assigned_application,serialize_application_history,serialize_application_history_item,serialize_candidate,serialize_candidate_profile,serialize_form_candidate_list,serialize_manual_candidate_list,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
|
||||
|
|
@ -1212,126 +1212,138 @@ class CandidateView:
|
|||
await assert_manager_candidate_access(
|
||||
self.session,current_user,user_id=user_id,created_by=created_by,
|
||||
)
|
||||
detail=bool(user_id)
|
||||
list_job_ids=None
|
||||
if not detail:
|
||||
list_job_ids=await job_post_ids_for_candidate_list(
|
||||
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||
return await self._get_candidate_detail(
|
||||
user_id,limit=limit,offset=offset,search=search,
|
||||
current_user=current_user,created_by=created_by,
|
||||
)
|
||||
if list_job_ids is not None and not list_job_ids:
|
||||
return []
|
||||
rows=await Inbox.get_candidate_profile(
|
||||
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
|
||||
job_post_ids=list_job_ids,
|
||||
list_job_ids=await job_post_ids_for_candidate_list(
|
||||
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||
)
|
||||
if detail:
|
||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
|
||||
if records and owned is not None:
|
||||
owned_set=set(owned)
|
||||
kept=[]
|
||||
for rec in records:
|
||||
msg=getattr(rec,"messages",None)
|
||||
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
|
||||
if jid and jid in owned_set:
|
||||
kept.append(rec)
|
||||
if kept:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(kept))
|
||||
records=[]
|
||||
if records:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(rows))
|
||||
# Manual uploads create users + manual_upload_candidate but no inbox
|
||||
# row — resolve the profile from that table instead of returning [].
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||
if not manual:
|
||||
return []
|
||||
if owned is not None:
|
||||
owned_set=set(owned)
|
||||
if not manual.job_post_id or manual.job_post_id not in owned_set:
|
||||
raise HTTPException(status_code=403,detail=_scope_detail(current_user))
|
||||
user=await Users.get_user_by_id(self.session,user_id)
|
||||
job_post=None
|
||||
if manual.job_post_id:
|
||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
||||
payload=serialize_manual_candidate_profile(manual,user,job_post)
|
||||
from inbox.plugins import get_ats_score_for_manual_user
|
||||
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
|
||||
if score:
|
||||
payload["ai_score"]=score["overall_score"]
|
||||
payload["recommendation"]=self._recommendation(score["overall_score"])
|
||||
payload["scored_at"]=score["computed_at"]
|
||||
payload["candidate_id"]=score.get("candidate_id")
|
||||
if score.get("user_id") and not payload.get("user_id"):
|
||||
payload["user_id"]=score["user_id"]
|
||||
if score.get("job_post_id"):
|
||||
payload["scored_job_post_id"]=score["job_post_id"]
|
||||
return await self.attach_application_history(payload)
|
||||
# List mode: inbox applications + manual/form applications (dedupe by user).
|
||||
inbox_payloads=await self.attach_job_posts(rows)
|
||||
if not isinstance(inbox_payloads,list):
|
||||
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
||||
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
|
||||
self.session,limit=limit,offset=0,search=search,job_post_ids=list_job_ids,
|
||||
if list_job_ids is not None and not list_job_ids:
|
||||
return []
|
||||
return await self._list_candidates(
|
||||
limit=limit,offset=offset,search=search,job_post_ids=list_job_ids,
|
||||
)
|
||||
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
|
||||
manual_payloads=[]
|
||||
for manual in manual_rows:
|
||||
uid=str(manual.user_id) if manual.user_id else None
|
||||
if uid and uid in seen:
|
||||
continue
|
||||
user=await Users.get_user_by_id(self.session,manual.user_id) if manual.user_id else None
|
||||
job_post=None
|
||||
if manual.job_post_id:
|
||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
||||
payload=serialize_manual_candidate_profile(manual,user,job_post)
|
||||
# List shape matches attach_job_posts: keep job_posts, drop heavy detail.
|
||||
manual_payloads.append({
|
||||
"inbox_id":None,
|
||||
"manual_upload_candidate_id":payload["manual_upload_candidate_id"],
|
||||
"user_id":payload["user_id"],
|
||||
"candidate_id":None,
|
||||
"name":payload["name"],
|
||||
"email":payload["email"],
|
||||
"is_active":payload.get("is_active"),
|
||||
"message_id":None,
|
||||
"created_at":payload.get("created_at"),
|
||||
"application_status":payload.get("application_status"),
|
||||
"experience":payload.get("experience"),
|
||||
"current_employment":payload.get("current_employment"),
|
||||
"current_title":payload.get("current_title"),
|
||||
"resume_text":None,
|
||||
"suggested_job_post_ids":[],
|
||||
"assigned_job_post_id":payload.get("assigned_job_post_id"),
|
||||
"job_posts":payload.get("job_posts") or [],
|
||||
"assigned_job_post":payload.get("assigned_job_post"),
|
||||
"job_title":payload.get("job_title"),
|
||||
"recruiter":payload.get("recruiter"),
|
||||
"recruiter_id":payload.get("recruiter_id"),
|
||||
"source":payload.get("source"),
|
||||
"file_path":payload.get("file_path"),
|
||||
"ai_score":None,
|
||||
"recommendation":None,
|
||||
})
|
||||
if uid:
|
||||
seen.add(uid)
|
||||
from inbox.plugins import get_ats_scores_for_users
|
||||
owners=[p.get("user_id") for p in manual_payloads if p.get("user_id")]
|
||||
ats=await get_ats_scores_for_users(self.session,owners)
|
||||
for payload in manual_payloads:
|
||||
row=ats.get(str(payload.get("user_id") or ""))
|
||||
if row and row.get("overall_score") is not None:
|
||||
payload["ai_score"]=row["overall_score"]
|
||||
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
|
||||
data=inbox_payloads+manual_payloads
|
||||
if assigned_job_post_id:
|
||||
job_id=str(assigned_job_post_id)
|
||||
data=[p for p in data if str(p.get("assigned_job_post_id") or "")==job_id]
|
||||
return await self.attach_application_history(data)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
async def _get_candidate_detail(self,user_id,limit=10,offset=0,search=None,current_user=None,created_by=False):
|
||||
rows=await Inbox.get_candidate_profile(
|
||||
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
|
||||
)
|
||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
|
||||
if records and owned is not None:
|
||||
owned_set=set(owned)
|
||||
kept=[]
|
||||
for rec in records:
|
||||
msg=getattr(rec,"messages",None)
|
||||
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
|
||||
if jid and jid in owned_set:
|
||||
kept.append(rec)
|
||||
if kept:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(kept))
|
||||
records=[]
|
||||
if records:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(rows))
|
||||
return await self._get_manual_detail(user_id,owned,current_user)
|
||||
|
||||
async def _get_manual_detail(self,user_id,owned,current_user):
|
||||
from inbox.plugins import get_ats_score_for_manual_user
|
||||
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||
if not manual:
|
||||
return []
|
||||
if owned is not None:
|
||||
owned_set=set(owned)
|
||||
if not manual.job_post_id or manual.job_post_id not in owned_set:
|
||||
raise HTTPException(status_code=403,detail=_scope_detail(current_user))
|
||||
user=await Users.get_user_by_id(self.session,user_id)
|
||||
job_post=None
|
||||
if manual.job_post_id:
|
||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
||||
payload=serialize_manual_candidate_profile(manual,user,job_post)
|
||||
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
|
||||
if score:
|
||||
payload["ai_score"]=score["overall_score"]
|
||||
payload["recommendation"]=self._recommendation(score["overall_score"])
|
||||
payload["scored_at"]=score["computed_at"]
|
||||
payload["candidate_id"]=score.get("candidate_id")
|
||||
if score.get("user_id") and not payload.get("user_id"):
|
||||
payload["user_id"]=score["user_id"]
|
||||
if score and score.get("job_post_id"):
|
||||
payload["scored_job_post_id"]=score["job_post_id"]
|
||||
return await self.attach_application_history(payload)
|
||||
|
||||
async def _list_candidates(self,limit=10,offset=0,search=None,job_post_ids=None):
|
||||
rows=await Inbox.get_candidate_profile(
|
||||
session=self.session,limit=limit,offset=offset,search=search,job_post_ids=job_post_ids,
|
||||
)
|
||||
inbox_payloads=await self.attach_job_posts(rows)
|
||||
if not isinstance(inbox_payloads,list):
|
||||
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
||||
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
|
||||
seen_emails={(p.get("email") or "").strip().lower() for p in inbox_payloads if (p.get("email") or "").strip()}
|
||||
manual_payloads=await self._list_manual_payloads(
|
||||
limit=limit,search=search,job_post_ids=job_post_ids,seen=seen,seen_emails=seen_emails,
|
||||
)
|
||||
form_payloads=await self._list_form_payloads(
|
||||
limit=limit,search=search,job_post_ids=job_post_ids,seen_emails=seen_emails,
|
||||
)
|
||||
return await self.attach_application_history(inbox_payloads+manual_payloads+form_payloads)
|
||||
|
||||
async def _list_manual_payloads(self,limit,search,job_post_ids,seen,seen_emails):
|
||||
rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
|
||||
self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids,
|
||||
)
|
||||
payloads=[]
|
||||
for row in rows:
|
||||
uid=str(row.user_id) if row.user_id else None
|
||||
if uid and uid in seen:
|
||||
continue
|
||||
user=await Users.get_user_by_id(self.session,row.user_id) if row.user_id else None
|
||||
job_post=None
|
||||
if row.job_post_id:
|
||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
|
||||
payload=serialize_manual_candidate_list(serialize_manual_candidate_profile(row,user,job_post))
|
||||
payloads.append(payload)
|
||||
if uid:
|
||||
seen.add(uid)
|
||||
email=(payload.get("email") or "").strip().lower()
|
||||
if email:
|
||||
seen_emails.add(email)
|
||||
await self._attach_user_ats(payloads)
|
||||
return payloads
|
||||
|
||||
async def _list_form_payloads(self,limit,search,job_post_ids,seen_emails):
|
||||
rows=await FormData.list_for_talent_pool(
|
||||
self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids,
|
||||
)
|
||||
payloads=[]
|
||||
for rec in rows:
|
||||
payload=serialize_form_candidate_list(rec)
|
||||
email=(payload.get("email") or "").strip().lower()
|
||||
if email and email in seen_emails:
|
||||
continue
|
||||
payloads.append(payload)
|
||||
if email:
|
||||
seen_emails.add(email)
|
||||
payloads=await self._hydrate_list_jobs(payloads)
|
||||
await self._attach_form_ats(payloads)
|
||||
return payloads
|
||||
|
||||
async def _attach_user_ats(self,payloads):
|
||||
from inbox.plugins import get_ats_scores_for_users
|
||||
owners=[p.get("user_id") for p in payloads if p.get("user_id")]
|
||||
ats=await get_ats_scores_for_users(self.session,owners)
|
||||
for payload in payloads:
|
||||
row=ats.get(str(payload.get("user_id") or ""))
|
||||
if row and row.get("overall_score") is not None:
|
||||
payload["ai_score"]=row["overall_score"]
|
||||
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
|
||||
|
||||
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
|
||||
try:
|
||||
job_post_ids=None
|
||||
|
|
@ -1339,9 +1351,20 @@ class CandidateView:
|
|||
job_post_ids=await job_post_ids_for_candidate_list(
|
||||
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||
)
|
||||
return await Inbox.count_candidate_profiles(
|
||||
if job_post_ids is not None and not job_post_ids:
|
||||
return 0
|
||||
inbox_n=await Inbox.count_candidate_profiles(
|
||||
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
|
||||
)
|
||||
if user_id:
|
||||
return inbox_n
|
||||
manual_n=await Manual_UPLOAD_CANDIDATE.count_for_talent_pool(
|
||||
self.session,search=search,job_post_ids=job_post_ids,
|
||||
)
|
||||
form_n=await FormData.count_for_talent_pool(
|
||||
self.session,search=search,job_post_ids=job_post_ids,
|
||||
)
|
||||
return inbox_n+manual_n+form_n
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -1410,6 +1433,47 @@ class CandidateView:
|
|||
data["job_title"]=payload.get("title")
|
||||
return payload
|
||||
|
||||
async def _hydrate_list_jobs(self,payloads):
|
||||
"""Attach assigned + suggested job posts onto already-serialized list rows."""
|
||||
wanted=[]
|
||||
for payload in payloads:
|
||||
if payload.get("assigned_job_post_id"):
|
||||
wanted.append(payload["assigned_job_post_id"])
|
||||
wanted.extend(payload.get("suggested_job_post_ids") or [])
|
||||
posts=await self._job_posts_by_id(wanted)
|
||||
for payload in payloads:
|
||||
assigned_id=payload.get("assigned_job_post_id")
|
||||
if assigned_id:
|
||||
post=posts.get(str(assigned_id))
|
||||
self._attach_job_post(payload,dict(post) if post else None,as_assigned=True)
|
||||
for job_id in payload.get("suggested_job_post_ids") or []:
|
||||
if assigned_id and str(job_id)==str(assigned_id):
|
||||
continue
|
||||
post=posts.get(str(job_id))
|
||||
self._attach_job_post(payload,dict(post) if post else None)
|
||||
return payloads
|
||||
|
||||
async def _attach_form_ats(self,payloads):
|
||||
grouped=await AtsResults.get_current_for_forms(
|
||||
self.session,[p.get("form_data_id") for p in payloads],
|
||||
)
|
||||
for payload in payloads:
|
||||
fid=payload.get("form_data_id")
|
||||
try:
|
||||
key=uuid.UUID(str(fid)) if fid else None
|
||||
except (TypeError,ValueError):
|
||||
key=None
|
||||
rows=grouped.get(key) or [] if key is not None else []
|
||||
assigned=payload.get("assigned_job_post_id")
|
||||
chosen=None
|
||||
if assigned:
|
||||
chosen=next((r for r in rows if str(r.job_post_id)==str(assigned)),None)
|
||||
if chosen is None and rows:
|
||||
chosen=rows[0]
|
||||
if chosen is not None and chosen.overall_score is not None:
|
||||
payload["ai_score"]=chosen.overall_score
|
||||
payload["recommendation"]=chosen.band or self._recommendation(chosen.overall_score)
|
||||
|
||||
async def _job_posts_by_id(self,ids):
|
||||
"""Serialized job posts keyed by id — one query for a whole page of rows.
|
||||
|
||||
|
|
|
|||
|
|
@ -72,9 +72,18 @@ const rejected = toApplicationListView({
|
|||
created_at: '2026-09-01T10:00:00Z',
|
||||
})
|
||||
ok('REJECTED maps to Rejected stage', rejected.stage === 'Rejected', `stage=${rejected.stage}`)
|
||||
ok('CLOSED also reads as Rejected', toApplicationListView({
|
||||
ok('CLOSED shows as CLOSED', toApplicationListView({
|
||||
inbox_id: 43, application_status: 'CLOSED', name: 'Closed',
|
||||
}).stage === 'Rejected')
|
||||
}).stage === 'CLOSED')
|
||||
|
||||
const formRow = toApplicationListView({
|
||||
form_data_id: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
|
||||
name: 'Form Applicant',
|
||||
source: 'Form',
|
||||
job_title: 'Brand Manager',
|
||||
})
|
||||
ok('form row key', formRow.id === 'form:ffffffff-ffff-ffff-ffff-ffffffffffff')
|
||||
ok('form with no pipeline status has no stage', formRow.stage == null)
|
||||
|
||||
const hired = toApplicationListView({
|
||||
inbox_id: 44,
|
||||
|
|
|
|||
|
|
@ -31,14 +31,14 @@ export function funnel({ fromDate, toDate, department, recruiterId } = {}) {
|
|||
/** Board column order used by the pipeline page. Rejected is last so callers
|
||||
* that drop outcomes can slice it off without re-sorting. */
|
||||
const BOARD_STAGE_ORDER = [
|
||||
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||
'CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||
'Approved', 'Hired', 'On Hold', 'Rejected',
|
||||
]
|
||||
|
||||
/**
|
||||
* Fold /analytics/funnel/fetch rows (11 enum statuses) onto the pipeline
|
||||
* columns. Same mapping the board uses, so CLOSED is Rejected and ONHOLD /
|
||||
* APPROVED keep their own columns rather than folding into Screening / Hired.
|
||||
* columns. Same mapping the board uses, so CLOSED stays CLOSED and only
|
||||
* REJECTED is Rejected. ONHOLD / APPROVED keep their own columns.
|
||||
*/
|
||||
export function toBoardStageRows(funnelRows, { includeRejected = false } = {}) {
|
||||
const folded = toStageCounts(
|
||||
|
|
|
|||
|
|
@ -238,6 +238,12 @@ function statusKey(value) {
|
|||
return String(value).toUpperCase()
|
||||
}
|
||||
|
||||
function listStageOf(status) {
|
||||
const key = statusKey(status)
|
||||
if (!key) return null
|
||||
return STAGE_FROM_STATUS[key] ?? 'Shortlist'
|
||||
}
|
||||
|
||||
function bandOf(score, recommendation) {
|
||||
if (recommendation) return recommendation
|
||||
if (score == null || !Number.isFinite(Number(score))) return null
|
||||
|
|
@ -261,12 +267,15 @@ export function toApplicationListView(row) {
|
|||
const rawScore = row.ai_score ?? row.match_score
|
||||
const aiScore = rawScore == null || rawScore === '' ? null : Number(rawScore)
|
||||
const score = Number.isFinite(aiScore) ? aiScore : null
|
||||
const id = row.inbox_id != null
|
||||
? `inbox:${row.inbox_id}`
|
||||
: (row.manual_upload_candidate_id
|
||||
? `manual:${row.manual_upload_candidate_id}`
|
||||
: (row.form_data_id
|
||||
? `form:${row.form_data_id}`
|
||||
: String(row.user_id || row.id || name)))
|
||||
return {
|
||||
id: row.inbox_id != null
|
||||
? `inbox:${row.inbox_id}`
|
||||
: (row.manual_upload_candidate_id
|
||||
? `manual:${row.manual_upload_candidate_id}`
|
||||
: String(row.user_id || row.id || name)),
|
||||
id,
|
||||
userId: row.user_id || null,
|
||||
name,
|
||||
email: row.email ?? null,
|
||||
|
|
@ -274,7 +283,7 @@ export function toApplicationListView(row) {
|
|||
jobTitle,
|
||||
recruiter: row.recruiter || null,
|
||||
applicationStatus: status || null,
|
||||
stage: status ? (STAGE_FROM_STATUS[status] ?? 'Shortlist') : null,
|
||||
stage: listStageOf(row.application_status ?? row.stage),
|
||||
source: row.source || null,
|
||||
aiScore: score,
|
||||
recommendation: bandOf(score, row.recommendation || null),
|
||||
|
|
|
|||
|
|
@ -17,9 +17,8 @@ import { toDate } from '../lib/format'
|
|||
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
||||
*
|
||||
* The enum has 11 values. Approved and On Hold are first-class columns (they
|
||||
* used to be folded into Hired / Screening). CLOSED is the inbox default for
|
||||
* an application that did not progress — it reads as Rejected, same as the
|
||||
* board's Rejected column, not as Shortlist.
|
||||
* used to be folded into Hired / Screening). CLOSED is the inbox default and
|
||||
* is shown as CLOSED. Only REJECTED reads as Rejected.
|
||||
*
|
||||
* Anything unmapped falls through to Shortlist rather than vanishing from the
|
||||
* board — a card with no column is a candidate nobody sees.
|
||||
|
|
@ -34,7 +33,7 @@ export const STAGE_FROM_STATUS = {
|
|||
OFFER: 'Offer',
|
||||
APPROVED: 'Approved',
|
||||
HIRED: 'Hired',
|
||||
CLOSED: 'Rejected',
|
||||
CLOSED: 'CLOSED',
|
||||
REJECTED: 'Rejected',
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +45,7 @@ export const STAGE_FROM_STATUS = {
|
|||
* (not CLOSED) so new drops are distinguishable from the inbox default.
|
||||
*/
|
||||
export const STATUS_FROM_STAGE = {
|
||||
CLOSED: 'CLOSED',
|
||||
Shortlist: 'PENDING',
|
||||
Screening: 'SCREENING',
|
||||
'On Hold': 'ONHOLD',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const SOURCE_LABEL = {
|
|||
}
|
||||
|
||||
const STAGE_BADGE = {
|
||||
CLOSED: 'b-gray',
|
||||
Shortlist: 'b-indigo',
|
||||
Screening: 'b-teal',
|
||||
Assessment: 'b-purple',
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { companies, moneyK, pick } from '../data/seed'
|
|||
Forms) → track (Notes, Activity) → audit (Timeline, History). */
|
||||
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', 'Timeline', 'History']
|
||||
// Forward progression for the live Advance button. Rejected has no next stage.
|
||||
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||
const KANBAN_ORDER = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||
|
||||
function tabFromSearch(tabParam, visibleTabs, fallback) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* ============================================================
|
||||
Candidates — applications on live backend data.
|
||||
|
||||
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
|
||||
Recruiter rows come from GET /candidate/fetch (inbox + manual + form), one row
|
||||
per application, so score / stage / job / recruiter have a source.
|
||||
Without candidates.manage the server scopes that list to jobs the user
|
||||
owns as recruiter (or created). Tick Requisitions → Configure in Access
|
||||
|
|
@ -38,7 +38,7 @@ import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '.
|
|||
|
||||
const EMPTY_FILTERS = { account: '', stage: '', band: '' }
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
const STAGE_FILTERS = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'On Hold', 'Rejected']
|
||||
const STAGE_FILTERS = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'On Hold', 'Rejected']
|
||||
const BAND_FILTERS = ['Strong Match', 'Potential Match', 'Weak Match', 'Unscored']
|
||||
const BAND_BADGE = {
|
||||
'Strong Match': 'b-green',
|
||||
|
|
@ -136,6 +136,7 @@ const REFERRAL_RE = new RegExp(
|
|||
const referralValue = (raw) => (raw || '').trim().toLowerCase()
|
||||
|
||||
const STAGE_BADGE = {
|
||||
CLOSED: 'b-gray',
|
||||
Shortlist: 'b-indigo',
|
||||
Screening: 'b-teal',
|
||||
Assessment: 'b-purple',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { ReappliedBadge } from '../components/ReapplicantHistory'
|
|||
|
||||
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
|
||||
export const KANBAN_STAGES = [
|
||||
{ name: 'CLOSED', color: 'var(--stage-9)' },
|
||||
{ name: 'Shortlist', color: 'var(--stage-1)' },
|
||||
{ name: 'Screening', color: 'var(--stage-2)' },
|
||||
{ name: 'Assessment', color: 'var(--stage-3)' },
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const RANGES = [
|
|||
]
|
||||
|
||||
/* Order matters: "reached" is a running sum from the end of this list back to
|
||||
the start. REJECTED, CLOSED (shown as Rejected on the board) and ONHOLD are
|
||||
the start. REJECTED, CLOSED (shown as CLOSED) and ONHOLD are
|
||||
absent — parking and outcomes are not a step in the happy-path suffix sum. */
|
||||
const FUNNEL_ORDER = [
|
||||
{ key: 'PENDING', label: 'Shortlist' },
|
||||
|
|
|
|||
Loading…
Reference in New Issue