filter appliede
parent
9036a48098
commit
d00ebcf6f5
|
|
@ -209,6 +209,8 @@ async def fetch_form_data(
|
|||
has_linkedin: bool | None = Query(None),
|
||||
has_resume: bool | None = Query(None),
|
||||
city: str | None = Query(None),
|
||||
source: str | None = Query(None),
|
||||
assigned: bool | None = Query(None),
|
||||
no_suggestions: bool | None = Query(None),
|
||||
offset: int = Query(0,ge=0),
|
||||
# Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged.
|
||||
|
|
@ -222,7 +224,8 @@ async def fetch_form_data(
|
|||
sheet=sheet,search=search,offset=offset,limit=limit,
|
||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||
has_linkedin=has_linkedin,has_resume=has_resume,
|
||||
city=_city_values(city),no_suggestions=no_suggestions,
|
||||
city=_city_values(city),source=(source or "").strip() or None,
|
||||
assigned=assigned,no_suggestions=no_suggestions,
|
||||
)
|
||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
@ -242,6 +245,8 @@ async def fetch_form_data_counts(
|
|||
has_linkedin: bool | None = Query(None),
|
||||
has_resume: bool | None = Query(None),
|
||||
city: str | None = Query(None),
|
||||
source: str | None = Query(None),
|
||||
assigned: bool | None = Query(None),
|
||||
current_user: dict = Depends(_FORM_DATA_READ),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
|
|
@ -249,7 +254,7 @@ async def fetch_form_data_counts(
|
|||
service=SheetFormData(session=session)
|
||||
data=await service.get_counts(
|
||||
sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume,
|
||||
city=_city_values(city),
|
||||
city=_city_values(city),source=(source or "").strip() or None,assigned=assigned,
|
||||
)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -129,7 +129,8 @@ class FormData(SQLModel, table=True):
|
|||
@classmethod
|
||||
def _filters(
|
||||
cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None,
|
||||
has_linkedin=None, has_resume=None, city=None, no_suggestions=None, inbox_filter=None,
|
||||
has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
|
||||
no_suggestions=None, inbox_filter=None,
|
||||
):
|
||||
filters = []
|
||||
if sheet:
|
||||
|
|
@ -161,6 +162,16 @@ class FormData(SQLModel, table=True):
|
|||
if cities:
|
||||
city_col = func.lower(func.coalesce(cls.city, cls.residing_city))
|
||||
filters.append(city_col.in_([c.lower() for c in cities]))
|
||||
if source:
|
||||
text = source.strip()
|
||||
lowered = text.lower()
|
||||
if lowered not in ("google sheet", "google_sheet", "sheet"):
|
||||
filters.append(cls.source_of_application.ilike(f"%{text}%"))
|
||||
if assigned is True:
|
||||
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
|
||||
elif assigned is False:
|
||||
filters.append(cls.assigned_job_post_id.is_(None))
|
||||
filters.append(cls.job_post_id.is_(None))
|
||||
if no_suggestions is True:
|
||||
filters.append(cls._no_suggested_jobs())
|
||||
if inbox_filter == "matched":
|
||||
|
|
@ -346,7 +357,8 @@ class FormData(SQLModel, table=True):
|
|||
async def fetch_form_data(
|
||||
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||
processing_state=None, is_duplicate=None, has_linkedin=None,
|
||||
has_resume=None, city=None, no_suggestions=None, inbox_filter=None,
|
||||
has_resume=None, city=None, source=None, assigned=None,
|
||||
no_suggestions=None, inbox_filter=None,
|
||||
offset=0, limit=None,
|
||||
):
|
||||
statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc())
|
||||
|
|
@ -354,7 +366,8 @@ class FormData(SQLModel, table=True):
|
|||
sheet=sheet, search=search,
|
||||
processing_state=processing_state, is_duplicate=is_duplicate,
|
||||
has_linkedin=has_linkedin, has_resume=has_resume,
|
||||
city=city, no_suggestions=no_suggestions, inbox_filter=inbox_filter,
|
||||
city=city, source=source, assigned=assigned,
|
||||
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
|
||||
):
|
||||
statement = statement.where(clause)
|
||||
if offset:
|
||||
|
|
@ -441,14 +454,16 @@ class FormData(SQLModel, table=True):
|
|||
async def count_form_data(
|
||||
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||
processing_state=None, is_duplicate=None, has_linkedin=None,
|
||||
has_resume=None, city=None, no_suggestions=None, inbox_filter=None,
|
||||
has_resume=None, city=None, source=None, assigned=None,
|
||||
no_suggestions=None, inbox_filter=None,
|
||||
):
|
||||
statement = select(func.count()).select_from(cls)
|
||||
for clause in cls._filters(
|
||||
sheet=sheet, search=search,
|
||||
processing_state=processing_state, is_duplicate=is_duplicate,
|
||||
has_linkedin=has_linkedin, has_resume=has_resume,
|
||||
city=city, no_suggestions=no_suggestions, inbox_filter=inbox_filter,
|
||||
city=city, source=source, assigned=assigned,
|
||||
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
|
||||
):
|
||||
statement = statement.where(clause)
|
||||
result = await session.execute(statement)
|
||||
|
|
@ -457,7 +472,7 @@ class FormData(SQLModel, table=True):
|
|||
@classmethod
|
||||
async def count_processing(
|
||||
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||
has_linkedin=None, has_resume=None, city=None,
|
||||
has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
|
||||
):
|
||||
"""Tab badge counts for the Sheet Forms channel.
|
||||
|
||||
|
|
@ -483,6 +498,7 @@ class FormData(SQLModel, table=True):
|
|||
for clause in cls._filters(
|
||||
sheet=sheet, search=search,
|
||||
has_linkedin=has_linkedin, has_resume=has_resume, city=city,
|
||||
source=source, assigned=assigned,
|
||||
):
|
||||
statement = statement.where(clause)
|
||||
row = (await session.execute(statement)).one()
|
||||
|
|
@ -512,6 +528,16 @@ class FormData(SQLModel, table=True):
|
|||
)
|
||||
return [text.strip() for text in result.scalars().all() if (text or "").strip()]
|
||||
|
||||
@classmethod
|
||||
async def distinct_sources(cls, session: AsyncSession):
|
||||
"""Non-blank source_of_application values. Distinct only within form_data."""
|
||||
result = await session.execute(
|
||||
select(cls.source_of_application)
|
||||
.where(cls.source_of_application.is_not(None), cls.source_of_application != "")
|
||||
.distinct()
|
||||
)
|
||||
return [text.strip() for text in result.scalars().all() if (text or "").strip()]
|
||||
|
||||
@classmethod
|
||||
async def delete_by_sheet(cls, session: AsyncSession, sheet: str, *, commit: bool = True):
|
||||
count_result = await session.execute(
|
||||
|
|
|
|||
|
|
@ -436,20 +436,20 @@ class SheetFormData(Sheet):
|
|||
async def get_form_data(
|
||||
self,sheet=None,search=None,offset=0,limit=None,
|
||||
processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None,
|
||||
city=None,no_suggestions=None,
|
||||
city=None,source=None,assigned=None,no_suggestions=None,
|
||||
):
|
||||
session=self._require_session()
|
||||
rows=await FormData.fetch_form_data(
|
||||
session,sheet=sheet,search=search,offset=offset,limit=limit,
|
||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||
no_suggestions=no_suggestions,
|
||||
source=source,assigned=assigned,no_suggestions=no_suggestions,
|
||||
)
|
||||
total=await FormData.count_form_data(
|
||||
session,sheet=sheet,search=search,
|
||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||
no_suggestions=no_suggestions,
|
||||
source=source,assigned=assigned,no_suggestions=no_suggestions,
|
||||
)
|
||||
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
|
||||
from job.candidate.views import CandidateView
|
||||
|
|
@ -600,10 +600,11 @@ class SheetFormData(Sheet):
|
|||
raise HTTPException(status_code=404,detail="Form data not found")
|
||||
return await self.get_form_data_by_id(record_id)
|
||||
|
||||
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None):
|
||||
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None,source=None,assigned=None):
|
||||
return await FormData.count_processing(
|
||||
self._require_session(),sheet=sheet,search=search,
|
||||
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||
source=source,assigned=assigned,
|
||||
)
|
||||
|
||||
async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None):
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ def _city_values(city: str | None):
|
|||
return parts or None
|
||||
|
||||
|
||||
def _apps_payload(items,total,cities=None):
|
||||
def _apps_payload(items,total,cities=None,sources=None):
|
||||
body={"data":items,"total":total,"status_code":200}
|
||||
if cities is not None:
|
||||
body["cities"]=cities
|
||||
if sources is not None:
|
||||
body["sources"]=sources
|
||||
return JSONResponse(content=body)
|
||||
|
||||
|
||||
|
|
@ -97,6 +99,8 @@ class ReadAllBody(BaseModel):
|
|||
is_duplicate: bool | None = None
|
||||
no_suggestions: bool | None = None
|
||||
processing_state: str | None = None
|
||||
city: str | None = None
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class TriageOverrideBody(BaseModel):
|
||||
|
|
@ -303,6 +307,8 @@ async def mark_all_inbox_read(
|
|||
is_duplicate=payload.is_duplicate,
|
||||
no_suggestions=payload.no_suggestions,
|
||||
processing_state=payload.processing_state,
|
||||
city=_city_values(payload.city),
|
||||
source=(payload.source or "").strip() or None,
|
||||
)
|
||||
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
@ -338,6 +344,7 @@ async def get_all_applications(
|
|||
processing_state: str | None = Query(default=None),
|
||||
search: str | None = Query(None),
|
||||
city: str | None = Query(None),
|
||||
source: str | None = Query(None),
|
||||
city_list: bool = Query(default=False),
|
||||
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
|
||||
top: int | None = Query(None, ge=1, le=500),
|
||||
|
|
@ -348,23 +355,25 @@ async def get_all_applications(
|
|||
try:
|
||||
service=Email(session=session)
|
||||
city_values=_city_values(city)
|
||||
source_value=(source or "").strip() or None
|
||||
cities=await service.list_cities() if city_list else None
|
||||
sources=await service.list_sources() if city_list else None
|
||||
|
||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
||||
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values)
|
||||
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values)
|
||||
return _apps_payload(items,total,cities)
|
||||
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
|
||||
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
|
||||
return _apps_payload(items,total,cities,sources)
|
||||
if isread==False:
|
||||
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values)
|
||||
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values)
|
||||
return _apps_payload(items,total,cities)
|
||||
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
|
||||
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
|
||||
return _apps_payload(items,total,cities,sources)
|
||||
if record_id:
|
||||
item=await service.get_application_by_id(record_id)
|
||||
return _apps_payload(item,1,cities)
|
||||
return _apps_payload(item,1,cities,sources)
|
||||
|
||||
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values)
|
||||
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values)
|
||||
return _apps_payload(items,total,cities)
|
||||
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value)
|
||||
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value)
|
||||
return _apps_payload(items,total,cities,sources)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from dotenv import load_dotenv
|
|||
from fastapi import HTTPException
|
||||
from inbox.enums import Candidate_application_Status
|
||||
from role.models import EnumRoles, Roles
|
||||
from sqlalchemy import Column, DateTime, case, func, or_, update
|
||||
from sqlalchemy import Column, DateTime, case, false, func, or_, update
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -1031,6 +1031,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
no_suggestions: bool | None=None,
|
||||
processing_state: str | None=None,
|
||||
city=None,
|
||||
source: str | None=None,
|
||||
inbox_filter: str | None=None,
|
||||
):
|
||||
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE.
|
||||
|
|
@ -1061,6 +1062,23 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
cities = [c.strip() for c in (city or []) if (c or "").strip()]
|
||||
if cities:
|
||||
statement = statement.where(func.lower(cls.city).in_([c.lower() for c in cities]))
|
||||
if source:
|
||||
text = source.strip()
|
||||
lowered = text.lower()
|
||||
if lowered in ("google sheet", "google_sheet", "sheet"):
|
||||
statement = statement.where(false())
|
||||
else:
|
||||
key = lowered.replace(" ", "_")
|
||||
channel_ids = select(SourceChannels.id).where(
|
||||
or_(
|
||||
func.lower(SourceChannels.label) == lowered,
|
||||
SourceChannels.key == key,
|
||||
)
|
||||
)
|
||||
statement = statement.where(or_(
|
||||
cls.source_channel_id.in_(channel_ids),
|
||||
cls.message_to.ilike(f"%{text}%"),
|
||||
))
|
||||
if inbox_filter == "matched":
|
||||
statement = statement.where(cls.match_status == "matched")
|
||||
elif inbox_filter == "unassigned":
|
||||
|
|
@ -1076,12 +1094,12 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
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, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, light: bool=False
|
||||
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, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None, light: bool=False
|
||||
):
|
||||
statement = cls._apply_filters(
|
||||
select(cls).order_by(cls.created_at.desc(), cls.id.desc()),
|
||||
search, isread, application_status, assigned, is_duplicate,
|
||||
no_suggestions, processing_state, city,
|
||||
no_suggestions, processing_state, city, source,
|
||||
)
|
||||
if skip:
|
||||
statement = statement.offset(skip)
|
||||
|
|
@ -1162,11 +1180,11 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
return {str(job_id): int(n) for job_id, n in result.all()}
|
||||
|
||||
@classmethod
|
||||
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None):
|
||||
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None):
|
||||
statement = cls._apply_filters(
|
||||
select(func.count()).select_from(cls),
|
||||
search, isread, application_status, assigned, is_duplicate,
|
||||
no_suggestions, processing_state, city,
|
||||
no_suggestions, processing_state, city, source,
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
|
@ -1283,6 +1301,8 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
is_duplicate: bool | None=None,
|
||||
processing_state: str | None=None,
|
||||
no_suggestions: bool | None=None,
|
||||
city=None,
|
||||
source: str | None=None,
|
||||
) -> int:
|
||||
"""Mark every row matching a list filter. Returns rows actually CHANGED.
|
||||
|
||||
|
|
@ -1291,7 +1311,10 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
was already read. It also keeps read_overridden_at off rows nobody decided
|
||||
anything about, so the Outlook sweep keeps its reach over untouched mail.
|
||||
"""
|
||||
statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate,no_suggestions,processing_state)
|
||||
statement=cls._apply_filters(
|
||||
update(cls),search,isread,application_status,assigned,is_duplicate,
|
||||
no_suggestions,processing_state,city,source,
|
||||
)
|
||||
statement=statement.where(cls.message_read!=bool(read))
|
||||
result=await session.execute(
|
||||
statement.values(message_read=bool(read),read_overridden_at=_now())
|
||||
|
|
@ -1834,6 +1857,49 @@ class AtsResults(SQLModel, table=True):
|
|||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_latest_by_job_for_inbox(cls, session: AsyncSession, inbox_id):
|
||||
"""Newest score per job_post_id for one inbox row (current or superseded)."""
|
||||
if inbox_id is None:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(cls)
|
||||
.where(cls.inbox_id == int(inbox_id))
|
||||
.order_by(cls.computed_at.desc())
|
||||
)
|
||||
by_job = {}
|
||||
for row in result.scalars():
|
||||
jid = str(row.job_post_id) if row.job_post_id else None
|
||||
if jid and jid not in by_job:
|
||||
by_job[jid] = row
|
||||
return list(by_job.values())
|
||||
|
||||
@classmethod
|
||||
async def get_latest_by_job_for_messages(cls, session: AsyncSession, message_ids) -> dict:
|
||||
"""{str(message_id): [newest score per job]} for a page of inbox messages."""
|
||||
keys = []
|
||||
for raw in message_ids or []:
|
||||
uid = cls._as_uuid(raw)
|
||||
if uid is not None:
|
||||
keys.append(uid)
|
||||
if not keys:
|
||||
return {}
|
||||
result = await session.execute(
|
||||
select(cls, Inbox.message_id)
|
||||
.join(Inbox, cls.inbox_id == Inbox.id)
|
||||
.where(Inbox.message_id.in_(keys))
|
||||
.order_by(cls.computed_at.desc())
|
||||
)
|
||||
grouped = {}
|
||||
for row, mid in result.all():
|
||||
if mid is None:
|
||||
continue
|
||||
bucket = grouped.setdefault(mid, {})
|
||||
jid = str(row.job_post_id) if row.job_post_id else None
|
||||
if jid and jid not in bucket:
|
||||
bucket[jid] = row
|
||||
return {str(mid): list(jobs.values()) for mid, jobs in grouped.items()}
|
||||
|
||||
@classmethod
|
||||
async def get_for_form_job(cls, session: AsyncSession, form_data_id, job_post_id):
|
||||
"""Any score for this form_data row against this job — current or superseded."""
|
||||
|
|
|
|||
|
|
@ -82,6 +82,18 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
|
|||
"match_error": message.match_error,
|
||||
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
||||
"resume_text": message.resume_text,
|
||||
"ats_score": message.ats_score,
|
||||
"ats_band": message.ats_band or None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_ats_result(row) -> dict:
|
||||
"""One inbox ats_results row — same keys the Sheet Forms cards paint."""
|
||||
return {
|
||||
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||
"overall_score": row.overall_score,
|
||||
"band": row.band or None,
|
||||
"computed_at": row.computed_at.isoformat() if row.computed_at else None,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
|
|||
except ValueError:
|
||||
raise PermanentTaskError("record_id and job_id must be uuids")
|
||||
|
||||
already=False
|
||||
results=[]
|
||||
async with session_scope() as session:
|
||||
link=await Inbox.get_inbox_by_message_id(session,mid)
|
||||
if link is not None:
|
||||
|
|
@ -46,22 +48,65 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
|
|||
# a user, so already_scored must not depend on it.
|
||||
existing=await AtsResults.get_for_inbox_job(session,link.id,jid)
|
||||
if existing is not None:
|
||||
return {"status":"already_scored"}
|
||||
job=await JobPosts.get_job_post_by_id(session,job_id)
|
||||
if job is None or job.is_deleted:
|
||||
raise PermanentTaskError("job post missing or deleted")
|
||||
service=CandidateScoring(session=session)
|
||||
try:
|
||||
# Attribute the rows to the job's owner — there is no request user
|
||||
# in a background task.
|
||||
results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)})
|
||||
except HTTPException as exc:
|
||||
# 400/404 from score_inbox are permanent (no attachment, bad ids);
|
||||
# retrying cannot fix them.
|
||||
raise PermanentTaskError(str(exc.detail)) from exc
|
||||
already=True
|
||||
if not already:
|
||||
job=await JobPosts.get_job_post_by_id(session,job_id)
|
||||
if job is None or job.is_deleted:
|
||||
raise PermanentTaskError("job post missing or deleted")
|
||||
service=CandidateScoring(session=session)
|
||||
try:
|
||||
# Attribute the rows to the job's owner — there is no request user
|
||||
# in a background task.
|
||||
results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)})
|
||||
except HTTPException as exc:
|
||||
# 400/404 from score_inbox are permanent (no attachment, bad ids);
|
||||
# retrying cannot fix them.
|
||||
raise PermanentTaskError(str(exc.detail)) from exc
|
||||
if already:
|
||||
# Assigning a job already scored as a suggestion must still flip the
|
||||
# denormed chip from max-of-suggestions to that job.
|
||||
await denorm_message_ats_score(record_id)
|
||||
return {"status":"already_scored"}
|
||||
await denorm_message_ats_score(record_id)
|
||||
return {"status":"scored","results":len(results)}
|
||||
|
||||
|
||||
async def denorm_message_ats_score(record_id:str) -> None:
|
||||
"""Stamp inbox_messages.ats_score: assigned job if set, else max of suggestions."""
|
||||
async with session_scope() as session:
|
||||
msg=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
|
||||
if msg is None:
|
||||
return
|
||||
link=await Inbox.get_inbox_by_message_id(session,record_id)
|
||||
if link is None:
|
||||
return
|
||||
rows=await AtsResults.get_latest_by_job_for_inbox(session,link.id)
|
||||
if not rows:
|
||||
return
|
||||
chosen=None
|
||||
assigned=msg.assigned_job_post_id
|
||||
if assigned:
|
||||
chosen=next((r for r in rows if str(r.job_post_id)==str(assigned)),None)
|
||||
if chosen is None:
|
||||
chosen=max(rows,key=lambda r:float(r.overall_score or 0))
|
||||
await Inbox_Messages.set_ats_score(session,record_id,chosen.overall_score,chosen.band)
|
||||
|
||||
|
||||
async def score_message_against_jobs(record_id:str,job_ids) -> None:
|
||||
"""Score one inbox CV against each job. Failures do not abort the rest."""
|
||||
seen=set()
|
||||
for raw in job_ids or []:
|
||||
job_id=str(raw or "").strip()
|
||||
if not job_id or job_id in seen:
|
||||
continue
|
||||
seen.add(job_id)
|
||||
try:
|
||||
outcome=await score_message_against_job(record_id,job_id)
|
||||
logger.info("ats auto-score %s vs %s: %s",record_id,job_id,outcome.get("status"))
|
||||
except Exception as exc:
|
||||
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,job_id,exc)
|
||||
|
||||
|
||||
@broker.task(
|
||||
task_name="g_sheet.score_form",
|
||||
retry_on_error=True,
|
||||
|
|
@ -166,23 +211,18 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
|||
status=status,
|
||||
error=result.get("error") or "",
|
||||
)
|
||||
# Auto-score: the match just paired this CV with jobs, so run the ATS on the
|
||||
# spot — assigned job first, else the agent's top suggestion. Scoring failures
|
||||
# must not fail the match; the match result is already committed above.
|
||||
# Auto-score every suggested job. The list chip is the max until a recruiter
|
||||
# assigns one; assignment then re-runs ATS against that job_post_id.
|
||||
# Scoring failures must not fail the match; the match result is committed above.
|
||||
suggested=[str(j) for j in (result.get("suggested_job_post_ids") or []) if j]
|
||||
score_job_id=None
|
||||
score_job_ids=list(suggested)
|
||||
async with session_scope() as session:
|
||||
fresh=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
|
||||
if fresh is not None and fresh.assigned_job_post_id:
|
||||
score_job_id=str(fresh.assigned_job_post_id)
|
||||
if score_job_id is None and suggested:
|
||||
score_job_id=suggested[0]
|
||||
if score_job_id:
|
||||
try:
|
||||
outcome=await score_message_against_job(record_id,score_job_id)
|
||||
logger.info("ats auto-score %s vs %s: %s",record_id,score_job_id,outcome.get("status"))
|
||||
except Exception as exc:
|
||||
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,score_job_id,exc)
|
||||
assigned=str(fresh.assigned_job_post_id)
|
||||
if assigned not in score_job_ids:
|
||||
score_job_ids.append(assigned)
|
||||
await score_message_against_jobs(record_id,score_job_ids)
|
||||
|
||||
return {
|
||||
"status":status,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import uuid
|
|||
import httpx,os
|
||||
from fastapi import HTTPException
|
||||
from inbox.enums import Candidate_application_Status
|
||||
from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun,Inbox
|
||||
from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun,Inbox,SourceChannels,AtsResults
|
||||
from inbox.file_decoder import extract_pdf_attachments
|
||||
from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run
|
||||
from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run, serialize_ats_result
|
||||
from inbox.plugins import (
|
||||
EMAIL_API_TOKEN,
|
||||
attach_email_pdfs_to_s3,
|
||||
|
|
@ -272,15 +272,16 @@ class Email:
|
|||
item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id)
|
||||
else:
|
||||
item["assigned_job_post"]=None
|
||||
return await cv.attach_application_history(item)
|
||||
items=await self._paint_inbox_ats([item])
|
||||
return await cv.attach_application_history(items[0])
|
||||
|
||||
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None):
|
||||
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None):
|
||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True)
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
|
||||
elif isread==False:
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True)
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
|
||||
else:
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True)
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
|
||||
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
|
||||
items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
|
||||
items=await self._attach_job_posts(items)
|
||||
|
|
@ -323,6 +324,46 @@ class Email:
|
|||
else:
|
||||
suggested.append(dict(payload))
|
||||
item["suggested_job_posts"]=suggested
|
||||
items=await self._paint_inbox_ats(items)
|
||||
return items
|
||||
|
||||
async def _paint_inbox_ats(self,items):
|
||||
"""Attach per-job ATS scores onto suggested/assigned posts and stamp max.
|
||||
|
||||
Unassigned rows show the highest suggestion score; assigned rows show
|
||||
the score against assigned_job_post_id — same rule as Sheet Forms.
|
||||
"""
|
||||
if not items:
|
||||
return items
|
||||
by_msg=await AtsResults.get_latest_by_job_for_messages(
|
||||
self.session,[item.get("id") for item in items],
|
||||
)
|
||||
for item in items:
|
||||
rows=by_msg.get(str(item.get("id") or "")) or []
|
||||
scores=[serialize_ats_result(r) for r in rows]
|
||||
item["ats_results"]=scores
|
||||
score_by_job={
|
||||
str(s["job_post_id"]):s for s in scores if s.get("job_post_id")
|
||||
}
|
||||
for post in item.get("suggested_job_posts") or []:
|
||||
hit=score_by_job.get(str(post.get("id")))
|
||||
if hit:
|
||||
post["overall_score"]=hit.get("overall_score")
|
||||
post["band"]=hit.get("band")
|
||||
assigned=item.get("assigned_job_post")
|
||||
if assigned:
|
||||
hit=score_by_job.get(str(assigned.get("id")))
|
||||
if hit:
|
||||
assigned["overall_score"]=hit.get("overall_score")
|
||||
assigned["band"]=hit.get("band")
|
||||
aid=item.get("assigned_job_post_id")
|
||||
assigned_score=score_by_job.get(str(aid)) if aid else None
|
||||
if assigned_score and assigned_score.get("overall_score") is not None:
|
||||
item["ats_score"]=round(float(assigned_score.get("overall_score")))
|
||||
else:
|
||||
nums=[s.get("overall_score") for s in scores if s.get("overall_score") is not None]
|
||||
if nums:
|
||||
item["ats_score"]=round(float(max(nums)))
|
||||
return items
|
||||
|
||||
async def get_application_by_id(self,record_id):
|
||||
|
|
@ -478,13 +519,13 @@ class Email:
|
|||
results.append({"email":email,"sent":False})
|
||||
return results
|
||||
|
||||
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None):
|
||||
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None):
|
||||
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state:
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city)
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
|
||||
elif isread==False:
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city)
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
|
||||
else:
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city)
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
|
||||
|
||||
async def list_cities(self):
|
||||
"""Distinct cities from inbox_messages and form_data, merged in Python."""
|
||||
|
|
@ -493,6 +534,15 @@ class Email:
|
|||
forms=await FormData.distinct_cities(self.session)
|
||||
return Reapplied(session=self.session).merge_cities(inbox,forms)
|
||||
|
||||
async def list_sources(self):
|
||||
"""Source / platform labels: seeded channels, Google Sheet, form sources."""
|
||||
from g_sheet.models import FormData
|
||||
channels=await SourceChannels.list_active(self.session)
|
||||
forms=await FormData.distinct_sources(self.session)
|
||||
return Reapplied(session=self.session).merge_cities(
|
||||
[c.label for c in channels],["Google Sheet"],forms,
|
||||
)
|
||||
|
||||
async def assign_job_post(self,record_id,job_post_id):
|
||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||
if not message:
|
||||
|
|
@ -550,7 +600,8 @@ class Email:
|
|||
|
||||
async def set_read_all(self,read,search=None,isread:bool=True,
|
||||
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
|
||||
assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
|
||||
assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,
|
||||
city=None,source=None):
|
||||
"""Mark every row the SAME filter set would have listed.
|
||||
|
||||
The filter arguments are the caller's current view, not a free-form query: the
|
||||
|
|
@ -561,6 +612,7 @@ class Email:
|
|||
self.session,read,search=search,isread=isread,
|
||||
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
|
||||
no_suggestions=no_suggestions,processing_state=processing_state,
|
||||
city=city,source=source,
|
||||
)
|
||||
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s",
|
||||
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function listMessages() {
|
|||
* `assigned` is tri-valued: omit for no filter, true for rows with an
|
||||
* assigned_job_post_id, false for the Job Matching queue.
|
||||
*/
|
||||
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, cityList } = {}) {
|
||||
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source, cityList } = {}) {
|
||||
return request('/inbox/all-applications', {
|
||||
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
|
||||
// to true = no filter), send false for the Unread tab only. buildUrl drops
|
||||
|
|
@ -31,8 +31,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
|
|||
// `no_suggestions`: Inbox On-Hold tab — no suggested job post linked.
|
||||
// `processing_state`: Processed / Rejected tabs (Move to Shortlist writes
|
||||
// processed, not application_status PROCESS).
|
||||
// `city`: optional comma-separated list. `city_list`: include merged distinct
|
||||
// cities from inbox_messages and form_data on the same response.
|
||||
// `city`: optional comma-separated list. `source`: channel / platform label.
|
||||
// `city_list`: include merged distinct cities and sources on the same response.
|
||||
params: {
|
||||
search,
|
||||
top,
|
||||
|
|
@ -45,6 +45,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
|
|||
no_suggestions: noSuggestions,
|
||||
processing_state: processingState,
|
||||
city,
|
||||
source,
|
||||
city_list: cityList,
|
||||
},
|
||||
})
|
||||
|
|
@ -128,7 +129,7 @@ export function bulkSetRead(recordIds, read) {
|
|||
* Resolves to `{updated, read}`, where `updated` counts rows that actually
|
||||
* CHANGED state, so it is safe to show in a toast.
|
||||
*/
|
||||
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState } = {}) {
|
||||
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source } = {}) {
|
||||
return request('/inbox/read-all', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
|
|
@ -140,6 +141,8 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned,
|
|||
is_duplicate: isDuplicate,
|
||||
no_suggestions: noSuggestions,
|
||||
processing_state: processingState,
|
||||
city,
|
||||
source,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ export function listFormDataSheets() {
|
|||
*/
|
||||
export function listFormData({
|
||||
sheet, search, offset = 0, limit, processing_state, is_duplicate,
|
||||
hasLinkedin, hasResume, city, no_suggestions,
|
||||
hasLinkedin, hasResume, city, source, assigned, no_suggestions,
|
||||
} = {}) {
|
||||
return request('/sheet/form-data/fetch', {
|
||||
params: {
|
||||
sheet, search, offset, limit, processing_state, is_duplicate,
|
||||
has_linkedin: hasLinkedin, has_resume: hasResume, city, no_suggestions,
|
||||
has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned, no_suggestions,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -47,9 +47,9 @@ export function countFormData({ sheet } = {}) {
|
|||
* processing_state or is_duplicate: those two ARE the tabs, and passing them
|
||||
* would make every badge report the tab the user is already on.
|
||||
*/
|
||||
export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city } = {}) {
|
||||
export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city, source, assigned } = {}) {
|
||||
return request('/sheet/form-data/counts', {
|
||||
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city },
|
||||
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badge
|
|||
// A group heading renders only if something under it survived the permission
|
||||
// filter — otherwise a low-privilege user sees orphaned section labels.
|
||||
const visible = ROUTES.filter((r) => {
|
||||
if (r.hidden) return false
|
||||
if (!can(r.permission)) return false
|
||||
if (isHiringManager(user) && !HIRING_MANAGER_NAV.has(r.path)) return false
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const ROUTES = [
|
|||
// --- Workspace ---
|
||||
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
|
||||
{ path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' },
|
||||
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' },
|
||||
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching', hidden: true },
|
||||
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
|
||||
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
|
||||
// Replaces Talent Pool. That screen browsed candidate accounts over a seed
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS }
|
|||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
/** Inbox list filters. '' = any. LinkedIn / Resume only apply on Sheet Forms. */
|
||||
const EMPTY_INBOX_FILTERS = { location: '', hasLinkedin: '', hasResume: '' }
|
||||
const EMPTY_INBOX_FILTERS = { location: '', source: '', assigned: '', hasLinkedin: '', hasResume: '' }
|
||||
|
||||
/** "Updated 4 min ago" under the search box — the honest label for a cached list. */
|
||||
function agoLabel(ts) {
|
||||
|
|
@ -254,6 +254,22 @@ function asAtsScore(value) {
|
|||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/** Assigned score if set, else max of per-job suggestion scores. */
|
||||
function emailAtsScore(row) {
|
||||
const assignedId = row?.assigned_job_post_id || row?.assignedId
|
||||
const results = Array.isArray(row?.ats_results) ? row.ats_results : []
|
||||
const posts = Array.isArray(row?.suggested_job_posts)
|
||||
? row.suggested_job_posts
|
||||
: (Array.isArray(row?.suggestedPosts) ? row.suggestedPosts : [])
|
||||
const assignedScore = results.find((s) => String(s.job_post_id) === String(assignedId))
|
||||
const fromResults = results.map((s) => asAtsScore(s.overall_score)).filter((n) => n != null)
|
||||
const fromJobs = posts.map((p) => asAtsScore(p.overall_score)).filter((n) => n != null)
|
||||
return asAtsScore(row?.ats_score)
|
||||
?? asAtsScore(assignedScore?.overall_score)
|
||||
?? (fromResults.length ? Math.max(...fromResults) : null)
|
||||
?? (fromJobs.length ? Math.max(...fromJobs) : null)
|
||||
}
|
||||
|
||||
/** Assigned job title for export — list rows may carry the post or only an id + jobPosts. */
|
||||
function assignedJobTitle(row) {
|
||||
const fromPost = row?.assignedPost?.title
|
||||
|
|
@ -493,6 +509,8 @@ async function fetchMessageDetail(recordId) {
|
|||
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
|
||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
||||
assignedPost: row.assigned_job_post || null,
|
||||
atsResults: Array.isArray(row.ats_results) ? row.ats_results : [],
|
||||
atsScore: emailAtsScore(row),
|
||||
isReapplicant: Boolean(row.is_reapplicant),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
|
|
@ -536,7 +554,7 @@ async function fetchApplications(params) {
|
|||
linkedinUrl: row.linkedin_url || '',
|
||||
// resume_text is not on the list payload (serialize_application
|
||||
// light=True). The detail query fetches it for the open row.
|
||||
atsScore: asAtsScore(row.ats_score),
|
||||
atsScore: emailAtsScore(row),
|
||||
phone: row.phone,
|
||||
experience: row.experience,
|
||||
recruiter: row.recruiter,
|
||||
|
|
@ -549,6 +567,7 @@ async function fetchApplications(params) {
|
|||
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
|
||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
||||
assignedPost: row.assigned_job_post || null,
|
||||
atsResults: Array.isArray(row.ats_results) ? row.ats_results : [],
|
||||
isReapplicant: Boolean(row.is_reapplicant),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
|
|
@ -892,10 +911,12 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
|
|||
* `minmax(280px, 34%)`. On any wide screen it would try to fit four columns into
|
||||
* about four hundred pixels. This stacks instead, like the Progress sidebar.
|
||||
*
|
||||
* Location is on every channel. LinkedIn / Resume stay Sheet Forms only — those
|
||||
* columns do not exist on email rows. Collapsed by default because extra selects
|
||||
* eat scarce vertical space above the queue, but the toggle carries the active
|
||||
* count so a collapsed panel cannot silently hide a filter.
|
||||
* City and Source / Platform are on every channel. Assigned job is too —
|
||||
* email and form rows both carry an assigned_job_post_id. LinkedIn / Resume
|
||||
* stay Sheet Forms only — those columns do not exist on email rows. Collapsed
|
||||
* by default because extra selects eat scarce vertical space above the queue,
|
||||
* but the toggle carries the active count so a collapsed panel cannot silently
|
||||
* hide a filter.
|
||||
*
|
||||
* (`Facet` in Candidates.jsx and CvBank.jsx is the same idea in a wider column.
|
||||
* Both files are mid-edit elsewhere, so this is deliberately a local copy rather
|
||||
|
|
@ -904,9 +925,10 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
|
|||
/** Junk location tokens — hide from the dropdown, not from the list query. */
|
||||
const HIDDEN_LOCATION = /^(KA|KAR|KARA|WAH)$/i
|
||||
|
||||
function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle, onChange, onClear }) {
|
||||
function InboxFilters({ filters, cities, sources, showLinkFilters, active, open, onToggle, onChange, onClear }) {
|
||||
const locations = (cities || []).filter((name) => !HIDDEN_LOCATION.test(String(name).trim()))
|
||||
const locationOk = filters.location && !HIDDEN_LOCATION.test(String(filters.location).trim())
|
||||
const platforms = sources || []
|
||||
return (
|
||||
<div className="inbox-filters">
|
||||
<button
|
||||
|
|
@ -925,9 +947,9 @@ function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle
|
|||
{open && (
|
||||
<div className="inbox-filter-stack">
|
||||
<div className="form-field">
|
||||
<label htmlFor="inbox-f-location">Location</label>
|
||||
<label htmlFor="inbox-f-city">City</label>
|
||||
<select
|
||||
id="inbox-f-location"
|
||||
id="inbox-f-city"
|
||||
value={filters.location}
|
||||
onChange={(e) => onChange('location', e.target.value)}
|
||||
>
|
||||
|
|
@ -940,6 +962,34 @@ function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="inbox-f-source">Source / Platform</label>
|
||||
<select
|
||||
id="inbox-f-source"
|
||||
value={filters.source || ''}
|
||||
onChange={(e) => onChange('source', e.target.value)}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{filters.source && !platforms.includes(filters.source) && (
|
||||
<option value={filters.source}>{filters.source}</option>
|
||||
)}
|
||||
{platforms.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="inbox-f-assigned">Assigned job</label>
|
||||
<select
|
||||
id="inbox-f-assigned"
|
||||
value={filters.assigned || ''}
|
||||
onChange={(e) => onChange('assigned', e.target.value)}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="yes">Assigned</option>
|
||||
<option value="no">Unassigned</option>
|
||||
</select>
|
||||
</div>
|
||||
{showLinkFilters && (
|
||||
<>
|
||||
<div className="form-field">
|
||||
|
|
@ -1060,6 +1110,12 @@ export default function Inbox() {
|
|||
setSelectedId(null)
|
||||
}, [])
|
||||
const city = inboxFilters.location
|
||||
const source = inboxFilters.source
|
||||
const assignedParams = inboxFilters.assigned === 'yes'
|
||||
? { assigned: true }
|
||||
: inboxFilters.assigned === 'no'
|
||||
? { assigned: false }
|
||||
: {}
|
||||
|
||||
const deepOpen = searchParams.get('open')
|
||||
const deepKind = searchParams.get('kind')
|
||||
|
|
@ -1085,6 +1141,8 @@ export default function Inbox() {
|
|||
const pageLimit = pageSize === 'all' ? undefined : pageSize
|
||||
const activeInboxFilters = [
|
||||
inboxFilters.location,
|
||||
inboxFilters.source,
|
||||
inboxFilters.assigned,
|
||||
...(isForms ? [inboxFilters.hasLinkedin, inboxFilters.hasResume] : []),
|
||||
].filter(Boolean).length
|
||||
|
||||
|
|
@ -1097,7 +1155,9 @@ export default function Inbox() {
|
|||
skip: pageLimit == null ? 0 : skip,
|
||||
...(search ? { search } : {}),
|
||||
...(city ? { city } : {}),
|
||||
}), [tabFilter, skip, pageLimit, search, city])
|
||||
...(source ? { source } : {}),
|
||||
...assignedParams,
|
||||
}), [tabFilter, skip, pageLimit, search, city, source, assignedParams])
|
||||
|
||||
/**
|
||||
* Sheet Forms only. On the All channel these rows are merged with email ones,
|
||||
|
|
@ -1119,18 +1179,28 @@ export default function Inbox() {
|
|||
...formTabFilter,
|
||||
...(search ? { search } : {}),
|
||||
...(city ? { city } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...assignedParams,
|
||||
...linkFilters,
|
||||
}), [formSheet, skip, pageLimit, search, city, formTabFilter, isAllChannel, linkFilters])
|
||||
}), [formSheet, skip, pageLimit, search, city, source, assignedParams, formTabFilter, isAllChannel, linkFilters])
|
||||
|
||||
const citiesQuery = useQuery({
|
||||
queryKey: qk.mailbox.cities(),
|
||||
queryFn: async () => {
|
||||
const res = await inboxApi.listApplications({ cityList: true, top: 1 })
|
||||
return Array.isArray(res?.cities) ? res.cities : []
|
||||
return {
|
||||
cities: Array.isArray(res?.cities) ? res.cities : [],
|
||||
sources: Array.isArray(res?.sources) ? res.sources : [],
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const cities = citiesQuery.data ?? []
|
||||
const cities = Array.isArray(citiesQuery.data)
|
||||
? citiesQuery.data
|
||||
: (citiesQuery.data?.cities ?? [])
|
||||
const sources = Array.isArray(citiesQuery.data)
|
||||
? []
|
||||
: (citiesQuery.data?.sources ?? [])
|
||||
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: qk.mailbox.applications(listParams),
|
||||
|
|
@ -1172,8 +1242,10 @@ export default function Inbox() {
|
|||
sheet: formCountsSheet,
|
||||
...(search ? { search } : {}),
|
||||
...(city ? { city } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...assignedParams,
|
||||
...linkFilters,
|
||||
}), [formCountsSheet, search, city, linkFilters])
|
||||
}), [formCountsSheet, search, city, source, assignedParams, linkFilters])
|
||||
const formCountsQuery = useQuery({
|
||||
queryKey: qk.mailbox.formCounts(formCountsParams),
|
||||
queryFn: async () => {
|
||||
|
|
@ -1288,7 +1360,7 @@ export default function Inbox() {
|
|||
const searchTotal = isAllChannel
|
||||
? (Number(applicationsQuery.data?.total ?? 0) + Number(formQuery.data?.total ?? 0))
|
||||
: (activeQuery.data?.total ?? 0)
|
||||
const total = (search || city)
|
||||
const total = (search || city || source || inboxFilters.assigned)
|
||||
? searchTotal
|
||||
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
|
||||
// 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing
|
||||
|
|
@ -1336,6 +1408,7 @@ export default function Inbox() {
|
|||
...selectedRow,
|
||||
...(detailQuery.data ?? {}),
|
||||
received: selectedRow?.received ?? detailQuery.data?.received ?? null,
|
||||
atsScore: asAtsScore(detailQuery.data?.atsScore) ?? asAtsScore(selectedRow?.atsScore) ?? null,
|
||||
}
|
||||
: null
|
||||
|
||||
|
|
@ -1385,7 +1458,12 @@ export default function Inbox() {
|
|||
setQ('')
|
||||
setSearch('') // clear the committed term too, or the new channel's first
|
||||
// fetch carries the old channel's search for 300ms
|
||||
setInboxFilters((f) => ({ ...EMPTY_INBOX_FILTERS, location: f.location }))
|
||||
setInboxFilters((f) => ({
|
||||
...EMPTY_INBOX_FILTERS,
|
||||
location: f.location,
|
||||
source: f.source,
|
||||
assigned: f.assigned,
|
||||
}))
|
||||
// Unread is email-only; leave it behind when opening Sheet Forms or All.
|
||||
if (next !== 'email' && tab === 'Unread') setTab('All Applications')
|
||||
selection.clear()
|
||||
|
|
@ -1407,7 +1485,13 @@ export default function Inbox() {
|
|||
if (SERVER_SCOPED_TABS.has(tab)) {
|
||||
setReadAll.mutate({
|
||||
read,
|
||||
filter: { ...tabFilter, ...(search ? { search } : {}) },
|
||||
filter: {
|
||||
...tabFilter,
|
||||
...(search ? { search } : {}),
|
||||
...(city ? { city } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...assignedParams,
|
||||
},
|
||||
// sheet rows carry no mailbox read state — email rows only
|
||||
ids: list.filter((i) => i.kind !== 'form').map((i) => i.id),
|
||||
})
|
||||
|
|
@ -1701,6 +1785,7 @@ export default function Inbox() {
|
|||
<InboxFilters
|
||||
filters={inboxFilters}
|
||||
cities={cities}
|
||||
sources={sources}
|
||||
showLinkFilters={isForms}
|
||||
active={activeInboxFilters}
|
||||
open={showInboxFilters}
|
||||
|
|
@ -2467,7 +2552,7 @@ function ApplicationDetail({
|
|||
{i.atsScore != null && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
|
||||
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
|
||||
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{Math.round(i.atsScore)}</div></div>
|
||||
</div>
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue