. #79
|
|
@ -2,48 +2,24 @@ name: Deploy to S3
|
|||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
# Nothing was verified before this existed: a frontend that failed to compile
|
||||
# would zip and ship exactly like a working one. `deploy` now needs this job,
|
||||
# so a red main does not reach the bucket.
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# 22 to match frontend/Dockerfile, so CI resolves the same tree the
|
||||
# production image builds from.
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Run checks
|
||||
run: bash scripts/ci-checks.sh
|
||||
|
||||
deploy:
|
||||
needs: checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# frontend/node_modules is excluded, and that is safe because of what
|
||||
# happens to this object downstream. CodeDeploy pulls it, extracts to
|
||||
# /opt/codedeploy-extracted-5, copies the tree to
|
||||
# /home/ec2-user/utopia-ai-hr-ats-portal-deployment-group and runs
|
||||
# `docker compose --env-file ./backend/.env up -d --build`. The only Node
|
||||
# service is the frontend, whose image does `npm ci` from the lockfile,
|
||||
# and frontend/.dockerignore excludes node_modules/ from the build context
|
||||
# outright. So the committed tree was carried into every artifact and then
|
||||
# thrown away unread. It was 90 MB of a 33 MB compressed upload.
|
||||
#
|
||||
# node_modules is still tracked in git, which is the reason it was here at
|
||||
# all. Untracking it is a separate change and affects other branches.
|
||||
- name: Configure AWS credentials
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
echo "AWS credentials configured"
|
||||
|
||||
- name: Archive project
|
||||
run: |
|
||||
apt-get update -y
|
||||
|
|
@ -51,9 +27,8 @@ jobs:
|
|||
zip -r utopia-ai-hr-ats-portal.zip . \
|
||||
-x ".git/*" \
|
||||
-x ".gitea/*" \
|
||||
-x ".gitignore" \
|
||||
-x "frontend/node_modules/*" \
|
||||
-x "*.DS_Store"
|
||||
-x ".gitignore/*" \
|
||||
-x "*.DS_Store"
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
|
|
@ -62,18 +37,19 @@ jobs:
|
|||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
./aws/install
|
||||
aws --version
|
||||
aws --version
|
||||
|
||||
# The credentials live only on this step. There used to be a separate
|
||||
# "Configure AWS credentials" step above that set the same three variables
|
||||
# and then only echoed a message — env: is scoped to its own step, so
|
||||
# those values were discarded before anything could use them. It was doing
|
||||
# nothing, and it read as though credentials were set up globally.
|
||||
- name: Upload files to S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
echo "Uploading repo contents to S3..."
|
||||
aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
|
||||
echo "Uploading repo contents to S3..."
|
||||
aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -103,17 +103,14 @@ def _clean_phone(value,resume_text):
|
|||
|
||||
|
||||
def _clean_city(value,resume_text):
|
||||
"""Optional residence city. Sentinel / invented → None. Never rejects the CV.
|
||||
"""Optional residence city. Sentinel → None. Never rejects the CV.
|
||||
|
||||
The prompt forbids work-experience cities; this only drops a value that is
|
||||
absent from the resume text or is the explicit empty sentinel.
|
||||
Proper city names (Karachi, not Karachi(Malir)) come from the OpenAI parse
|
||||
in run_employment_agent. This clamp does not rewrite place names.
|
||||
"""
|
||||
text=(value or "").strip()
|
||||
if not text or text.lower() in (NO_CITY.lower(),"none","null","n/a","-"):
|
||||
return None
|
||||
haystack=(resume_text or "").lower()
|
||||
if haystack and text.lower() not in haystack:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,15 +6,67 @@ Called from inbox.tasks.match_inbox_message; no HTTP surface.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from employment_agent.decorators import parse_employment_response
|
||||
from employment_agent.prompt import CURRENT_TITLE,EDUCATION,NO_COMPANY,prompt,user_prompt
|
||||
from employment_agent.prompt import CURRENT_TITLE,EDUCATION,NO_CITY,NO_COMPANY,city_list_prompt,prompt,user_prompt
|
||||
from llm_setup import llm_call
|
||||
|
||||
logger=logging.getLogger("employment_agent")
|
||||
|
||||
|
||||
def parse_normalized_cities(data,fallback=None):
|
||||
"""Keep unique proper city names from the list-normalizer JSON."""
|
||||
rows=None
|
||||
if isinstance(data,dict):
|
||||
rows=data.get("cities")
|
||||
if not isinstance(rows,list):
|
||||
return list(fallback or [])
|
||||
out=[]
|
||||
seen=set()
|
||||
for item in rows:
|
||||
if not isinstance(item,str):
|
||||
continue
|
||||
text=item.strip()
|
||||
if not text or text.lower() in (NO_CITY.lower(),"none","null","n/a","-"):
|
||||
continue
|
||||
key=text.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(text)
|
||||
out.sort(key=str.lower)
|
||||
return out or list(fallback or [])
|
||||
|
||||
|
||||
async def normalize_cities(values):
|
||||
"""OpenAI: messy stored places → the same proper city names the CV agent writes."""
|
||||
places=[]
|
||||
seen=set()
|
||||
for raw in values or []:
|
||||
text=(raw or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
key=text.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
places.append(text)
|
||||
if not places:
|
||||
return []
|
||||
try:
|
||||
data=await llm_call(
|
||||
city_list_prompt(),
|
||||
json.dumps({"places":places},ensure_ascii=False),
|
||||
json_mode=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("city list normalize failed")
|
||||
return places
|
||||
return parse_normalized_cities(data,fallback=places)
|
||||
|
||||
|
||||
async def run_employment_agent(*,resume_text=""):
|
||||
text=(resume_text or "").strip()
|
||||
if not text:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ NO_LINKEDIN="no linkedin url mentioned"
|
|||
NO_PHONE="no phone number mentioned"
|
||||
NO_CITY="no city mentioned"
|
||||
|
||||
CITY_POLICY=f"""- Return ONE proper city name only — the city, not an area, town, sector, housing society, cantonment, district, or parenthetical locality.
|
||||
- Correct values look like "Karachi", "Lahore", "Islamabad", "Rawalpindi", "Peshawar". Not "Karachi(Malir)", not "Wah Cantt", not "Gulberg Lahore".
|
||||
- If the text names a neighborhood or area of a city, return that city: "Karachi (Malir)" / "Karachi(Malir)" / "DHA Karachi" → "Karachi". "Gulberg, Lahore" → "Lahore". "F-10 Islamabad" → "Islamabad".
|
||||
- Drop "Cantt" / "Cantonment": "Lahore Cantt" → "Lahore", "Rawalpindi Cantt" → "Rawalpindi", "Wah Cantt" → "Wah".
|
||||
- Never concatenate two places. If the string is messy (for example "Karachi(Malir) Wah Cantt"), return the single residence city, not both strings glued together.
|
||||
- Do not return province, country, street, house number, or text inside parentheses.
|
||||
- Drop junk tokens such as KA, KAR, KARA, empty values, and unintelligible strings."""
|
||||
|
||||
|
||||
def prompt():
|
||||
return f"""You are an HR-ATS recruiting assistant.
|
||||
|
|
@ -57,11 +65,10 @@ linkedin_url (its own key — extract this separately from the other fields):
|
|||
- Never guess a slug or construct linkedin.com/in/<name> from the candidate's name. The stored value will be null when this sentinel is returned.
|
||||
|
||||
city (its own key — OPTIONAL. A missing city must not fail the candidate):
|
||||
- Return the city of residence only, city name alone (for example "Karachi", "Lahore", "Islamabad"), using the resume's own spelling.
|
||||
{CITY_POLICY}
|
||||
- Extract city ONLY from the candidate's contact / location / address header (the block with name, phone, email, LinkedIn, "Address", "Location", "based in", "currently living in").
|
||||
- Do NOT extract city from Work Experience. A job that lists Karachi, UAE, USA, or any other city is the employer's location, not proof the candidate lives there.
|
||||
- If the contact/location section does not name a city, return exactly: {NO_CITY}. Leave it blank rather than guessing from jobs, education, or nationality.
|
||||
- The city string you return MUST appear verbatim (or as a clear substring) in that contact/location section of the resume text.
|
||||
|
||||
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.
|
||||
|
|
@ -118,6 +125,14 @@ Example 9 — contact/location city is residence:
|
|||
Resume: "Ali Khan | Location: Lahore | 0321-5551234\\nExperience: Acme, Karachi, Engineer"
|
||||
JSON city must be "Lahore". Not "Karachi".
|
||||
|
||||
Example 10 — neighborhood / cantonment is not the city:
|
||||
Resume: "Ali Khan | Karachi(Malir) | 0321-5551234"
|
||||
JSON city must be "Karachi". Not "Karachi(Malir)" and not "Malir".
|
||||
|
||||
Example 11 — do not glue two place fragments:
|
||||
Resume: "Address: Karachi(Malir) Wah Cantt"
|
||||
JSON city must be "Karachi" (one city). Not "Karachi(Malir) Wah Cantt" and not "Wah Cantt".
|
||||
|
||||
Respond with JSON only:
|
||||
{{
|
||||
"current_employment": "Company Name",
|
||||
|
|
@ -135,3 +150,16 @@ If the contact/location section has no city, city must be "{NO_CITY}" — still
|
|||
|
||||
def user_prompt(resume_text:str) -> str:
|
||||
return json.dumps({"resume_text":resume_text or ""},ensure_ascii=False)
|
||||
|
||||
|
||||
def city_list_prompt():
|
||||
"""Map messy stored place strings to the same proper city names the CV agent writes."""
|
||||
return f"""You map messy residence strings to proper city names for an Inbox City filter.
|
||||
|
||||
{CITY_POLICY}
|
||||
|
||||
Input is JSON: {{"places": ["Karachi(Malir)", "Wah Cantt", "Lahore"]}}
|
||||
Respond with JSON only:
|
||||
{{"cities": ["Karachi", "Wah", "Lahore"]}}
|
||||
Unique proper city names only. Do not copy raw neighborhood or cantonment strings into cities.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -203,15 +203,13 @@ async def fetch_form_data(
|
|||
search: str | None = Query(None),
|
||||
processing_state: str | None = Query(None),
|
||||
is_duplicate: bool | None = Query(None),
|
||||
# Tri-valued, like is_duplicate above: omit for no filter, true for rows that
|
||||
# have the link, false for the ones missing it. Chasing the gaps is half the
|
||||
# reason these exist, so `false` has to be a real filter and not "unset".
|
||||
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.
|
||||
limit: int | None = Query(None,ge=1,le=500),
|
||||
current_user: dict = Depends(_FORM_DATA_READ),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
|
@ -222,7 +220,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 +241,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 +250,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:
|
||||
|
|
|
|||
|
|
@ -126,10 +126,23 @@ class FormData(SQLModel, table=True):
|
|||
else_=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cities_match(column, cities):
|
||||
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""
|
||||
clauses = []
|
||||
for city in cities or []:
|
||||
text = (city or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
safe = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
clauses.append(column.ilike(f"%{safe}%", escape="\\"))
|
||||
return or_(*clauses) if clauses else None
|
||||
|
||||
@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:
|
||||
|
|
@ -159,8 +172,19 @@ class FormData(SQLModel, table=True):
|
|||
)
|
||||
cities = [c.strip() for c in (city or []) if (c or "").strip()]
|
||||
if cities:
|
||||
city_col = func.lower(func.coalesce(cls.city, cls.residing_city))
|
||||
filters.append(city_col.in_([c.lower() for c in cities]))
|
||||
clause = cls._cities_match(func.coalesce(cls.city, cls.residing_city), cities)
|
||||
if clause is not None:
|
||||
filters.append(clause)
|
||||
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 +370,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 +379,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 +467,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 +485,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 +511,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 +541,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(
|
||||
|
|
|
|||
|
|
@ -340,23 +340,23 @@ class SheetFormData(Sheet):
|
|||
"""FormData DB mirror — query / delete only (no Google client)."""
|
||||
|
||||
async def _hydrate_job_posts(self,items):
|
||||
"""Attach suggested job_posts, assigned_job_post, and per-job ATS scores.
|
||||
"""Attach suggested job titles, assigned_job_post, and per-job ATS scores.
|
||||
|
||||
Preferred source is suggested_job_post_ids (ILIKE matches stored on
|
||||
import). Legacy rows without that list still title-match. ATS is one
|
||||
current score per (form, job).
|
||||
current score per (form, job). Full JD loads when a card is expanded.
|
||||
"""
|
||||
if not items:
|
||||
return items
|
||||
from g_sheet.scoring import serialize_form_ats
|
||||
from inbox.models import AtsResults
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
from job.job_post.serializers import serialize_job_post_title
|
||||
|
||||
session=self._require_session()
|
||||
|
||||
def _job_payload(post):
|
||||
payload=serialize_job_post(post)
|
||||
payload=serialize_job_post_title(post)
|
||||
if post.is_deleted or not post.is_active:
|
||||
payload={**payload,"unavailable":True}
|
||||
return payload
|
||||
|
|
@ -374,7 +374,7 @@ class SheetFormData(Sheet):
|
|||
wanted=list(dict.fromkeys([*suggested_ids,*assigned_ids]))
|
||||
by_id={}
|
||||
if wanted:
|
||||
for post in await JobPosts.get_by_ids(session,wanted,active_only=False):
|
||||
for post in await JobPosts.titles_by_ids(session,wanted,active_only=False):
|
||||
by_id[str(post.id)]=_job_payload(post)
|
||||
|
||||
titles=[(item.get("position_applied_for") or "").strip() for item in items]
|
||||
|
|
@ -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
|
||||
|
|
@ -1022,6 +1022,18 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
else_=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cities_match(column, cities):
|
||||
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""
|
||||
clauses = []
|
||||
for city in cities or []:
|
||||
text = (city or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
safe = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
clauses.append(column.ilike(f"%{safe}%", escape="\\"))
|
||||
return or_(*clauses) if clauses else None
|
||||
|
||||
@classmethod
|
||||
def _apply_filters(
|
||||
cls, statement, search: str | None=None, isread: bool=True,
|
||||
|
|
@ -1031,6 +1043,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.
|
||||
|
|
@ -1060,7 +1073,26 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
statement = statement.where(cls.processing_state == processing_state)
|
||||
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]))
|
||||
clause = cls._cities_match(cls.city, cities)
|
||||
if clause is not None:
|
||||
statement = statement.where(clause)
|
||||
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 +1108,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 +1194,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 +1315,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 +1325,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 +1871,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."""
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
|
@ -138,6 +137,7 @@ def load_file_bytes(path_or_url: str) -> bytes | None:
|
|||
|
||||
|
||||
def load_message_files(message:Inbox_Messages) -> list[dict]:
|
||||
"""Filename + public URL only. Open-resume uses the S3 link; do not pull bytes."""
|
||||
if not message.file_path:
|
||||
return []
|
||||
names=[n.strip() for n in (message.file_name or "").split(",") if n.strip()]
|
||||
|
|
@ -147,22 +147,6 @@ def load_message_files(message:Inbox_Messages) -> list[dict]:
|
|||
entry={"file_name":name or "resume.pdf","url":None,"content_base64":None,"size":0}
|
||||
if path_str.lower().startswith("http://") or path_str.lower().startswith("https://"):
|
||||
entry["url"]=path_str
|
||||
raw=load_file_bytes(path_str)
|
||||
if raw is not None:
|
||||
entry["content_base64"]=base64.b64encode(raw).decode("ascii")
|
||||
entry["size"]=len(raw)
|
||||
files.append(entry)
|
||||
continue
|
||||
path=resolve_attachment_path(path_str)
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
raw=path.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
entry["file_name"]=path.name
|
||||
entry["content_base64"]=base64.b64encode(raw).decode("ascii")
|
||||
entry["size"]=len(raw)
|
||||
files.append(entry)
|
||||
return files
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -256,31 +256,16 @@ class Email:
|
|||
item["files"]=files
|
||||
from job.candidate.views import CandidateView
|
||||
cv=CandidateView(session=self.session)
|
||||
suggested=[]
|
||||
for job_id in item.get("suggested_job_post_ids") or []:
|
||||
jp=await cv.get_job_post_by_id(record_id=job_id)
|
||||
if jp:
|
||||
if jp.get("is_deleted") or not jp.get("is_active"):
|
||||
suggested.append({**jp,"unavailable":True})
|
||||
else:
|
||||
suggested.append(jp)
|
||||
else:
|
||||
suggested.append({"id":str(job_id),"unavailable":True})
|
||||
item["suggested_job_posts"]=suggested
|
||||
assigned_id=item.get("assigned_job_post_id")
|
||||
if assigned_id:
|
||||
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._attach_job_posts([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)
|
||||
|
|
@ -288,10 +273,11 @@ class Email:
|
|||
return await CandidateView(session=self.session).attach_application_history(items)
|
||||
|
||||
async def _attach_job_posts(self,items):
|
||||
"""List payload needs assigned + suggested job objects — export reads titles.
|
||||
"""List payload needs assigned + suggested titles — export reads names.
|
||||
|
||||
Detail hydrates one row; the queue used to ship ids only. Assigned job and
|
||||
Suggested jobs in the Inbox .xlsx were then blank for email applicants.
|
||||
Detail hydrates one row. Full JD (location, requirements) loads when
|
||||
the recruiter expands a card. Assigned job and Suggested jobs in the
|
||||
Inbox .xlsx stay as titles.
|
||||
"""
|
||||
ids=[]
|
||||
for item in items:
|
||||
|
|
@ -304,9 +290,9 @@ class Email:
|
|||
by_id={}
|
||||
if ids:
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
for post in await JobPosts.get_by_ids(self.session,ids,active_only=False):
|
||||
payload=serialize_job_post(post)
|
||||
from job.job_post.serializers import serialize_job_post_title
|
||||
for post in await JobPosts.titles_by_ids(self.session,ids,active_only=False):
|
||||
payload=serialize_job_post_title(post)
|
||||
if post.is_deleted or not post.is_active:
|
||||
payload={**payload,"unavailable":True}
|
||||
by_id[str(post.id)]=payload
|
||||
|
|
@ -323,6 +309,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,20 +504,31 @@ 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."""
|
||||
"""Proper city names for the Inbox filter — same OpenAI mapping as CV parse."""
|
||||
from employment_agent.execute_agent import normalize_cities
|
||||
from g_sheet.models import FormData
|
||||
inbox=await Inbox_Messages.distinct_cities(self.session)
|
||||
forms=await FormData.distinct_cities(self.session)
|
||||
return Reapplied(session=self.session).merge_cities(inbox,forms)
|
||||
merged=Reapplied(session=self.session).merge_cities(inbox,forms)
|
||||
return await normalize_cities(merged)
|
||||
|
||||
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)
|
||||
|
|
@ -550,7 +587,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 +599,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)
|
||||
|
|
|
|||
|
|
@ -1713,26 +1713,9 @@ class CandidateView:
|
|||
email=_norm_email(row.get("email"))
|
||||
if email not in packed:
|
||||
continue
|
||||
packed[email]["applications"].append(row)
|
||||
# ATS scores are job-assignment history, not applications.
|
||||
if "candidates" not in packed[email]["present_in"]:
|
||||
packed[email]["present_in"].append("candidates")
|
||||
pipeline_jobs={}
|
||||
for email,pack in packed.items():
|
||||
jobs=set()
|
||||
for row in pack["applications"]:
|
||||
if row.get("source") in ("inbox","manual") and row.get("job_post_id"):
|
||||
jobs.add(row["job_post_id"])
|
||||
pipeline_jobs[email]=jobs
|
||||
for email,pack in packed.items():
|
||||
jobs=pipeline_jobs.get(email) or set()
|
||||
if not jobs:
|
||||
continue
|
||||
# ATS scores for a job the person already applied to are not a
|
||||
# separate application — they duplicate the pipeline row.
|
||||
pack["applications"]=[
|
||||
row for row in pack["applications"]
|
||||
if not (row.get("source")=="ats" and row.get("job_post_id") in jobs)
|
||||
]
|
||||
for pack in packed.values():
|
||||
pack["applications"].sort(key=lambda r: r.get("applied_at") or "",reverse=True)
|
||||
present=pack["present_in"]
|
||||
|
|
@ -1751,7 +1734,11 @@ class CandidateView:
|
|||
)
|
||||
|
||||
async def attach_application_history(self,payloads):
|
||||
"""Stamp is_reapplicant + previous_applications onto list/detail dicts."""
|
||||
"""Stamp is_reapplicant + previous_applications onto list/detail dicts.
|
||||
|
||||
``previous_applications`` is every application for that email, including
|
||||
the open row. ``is_reapplicant`` still means a *different* assigned job.
|
||||
"""
|
||||
single=not isinstance(payloads,list)
|
||||
records=[payloads] if single else list(payloads or [])
|
||||
emails=[_payload_email(p) for p in records]
|
||||
|
|
@ -1761,15 +1748,15 @@ class CandidateView:
|
|||
continue
|
||||
email=_payload_email(payload)
|
||||
pack=history.get(email) or {"present_in":[],"user":None,"applications":[]}
|
||||
previous=[]
|
||||
items=[]
|
||||
reapplied=False
|
||||
for row in pack.get("applications") or []:
|
||||
if _is_current_application(row,payload):
|
||||
continue
|
||||
if not _is_earlier_application(row,payload):
|
||||
continue
|
||||
previous.append(serialize_application_history_item(row))
|
||||
previous.sort(key=lambda r: r.get("applied_at") or "",reverse=True)
|
||||
item=serialize_application_history_item(row)
|
||||
items.append(item)
|
||||
if is_assigned_application(item) and not _is_current_application(row,payload):
|
||||
reapplied=True
|
||||
items.sort(key=lambda r: r.get("applied_at") or "",reverse=True)
|
||||
payload["present_in"]=list(pack.get("present_in") or [])
|
||||
payload["is_reapplicant"]=any(is_assigned_application(item) for item in previous)
|
||||
payload["previous_applications"]=previous
|
||||
payload["is_reapplicant"]=reapplied
|
||||
payload["previous_applications"]=items
|
||||
return records[0] if single else records
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Optional
|
|||
|
||||
from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm import aliased, load_only
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
from job.job_post.enums import RequisitionStatus
|
||||
|
|
@ -127,6 +127,31 @@ class JobPosts(SQLModel, table=True):
|
|||
# Preserve request order so suggestion ranks stay stable.
|
||||
return [by_id[str(u)] for u in uids if str(u) in by_id]
|
||||
|
||||
@classmethod
|
||||
async def titles_by_ids(cls, session: AsyncSession, ids: list[str], *, active_only: bool = False):
|
||||
"""id + title + status only — inbox suggestion rail before a card expands.
|
||||
|
||||
Skips description / post_text TOAST columns. Rank order matches `ids`.
|
||||
"""
|
||||
uids = []
|
||||
for raw in ids or []:
|
||||
uid = cls._as_uuid(raw)
|
||||
if uid is not None:
|
||||
uids.append(uid)
|
||||
if not uids:
|
||||
return []
|
||||
statement = (
|
||||
select(cls)
|
||||
.options(load_only(cls.id, cls.title, cls.status, cls.is_active, cls.is_deleted))
|
||||
.where(cls.id.in_(uids))
|
||||
)
|
||||
if active_only:
|
||||
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
|
||||
result = await session.execute(statement)
|
||||
rows = list(result.scalars().all())
|
||||
by_id = {str(r.id): r for r in rows}
|
||||
return [by_id[str(u)] for u in uids if str(u) in by_id]
|
||||
|
||||
@classmethod
|
||||
async def get_by_titles(cls, session: AsyncSession, titles: list[str], *, active_only: bool = False):
|
||||
"""Match job posts whose title equals any of `titles` (trim + case-insensitive).
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ def _status_label(value):
|
|||
return parsed.label if parsed else value
|
||||
|
||||
|
||||
def serialize_job_post_title(row) -> dict:
|
||||
"""Inbox suggestion rail — title only until the recruiter expands the card."""
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"title": row.title,
|
||||
"status": row.status,
|
||||
"is_active": row.is_active,
|
||||
"is_deleted": row.is_deleted,
|
||||
}
|
||||
|
||||
|
||||
def serialize_job_post(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
|
|
|
|||
|
|
@ -89,3 +89,27 @@ def test_adds_scheme_and_rejects_company_page():
|
|||
"",
|
||||
)
|
||||
assert company_page["linkedin_url"] is None
|
||||
|
||||
|
||||
def test_city_prompt_asks_openai_for_a_proper_city_name():
|
||||
from employment_agent.prompt import city_list_prompt, prompt
|
||||
text = prompt()
|
||||
assert "Karachi(Malir)" in text
|
||||
assert 'JSON city must be "Karachi"' in text
|
||||
assert "Return ONE proper city name only" in text
|
||||
listed = city_list_prompt()
|
||||
assert "Karachi(Malir)" in listed
|
||||
assert '"cities"' in listed
|
||||
|
||||
|
||||
def test_parse_normalized_cities_keeps_agent_names():
|
||||
from employment_agent.execute_agent import parse_normalized_cities
|
||||
assert parse_normalized_cities(
|
||||
{"cities": ["Karachi", "Karachi", "Lahore", "no city mentioned"]},
|
||||
fallback=["Karachi(Malir)"],
|
||||
) == ["Karachi", "Lahore"]
|
||||
|
||||
|
||||
def test_parse_normalized_cities_falls_back_when_the_model_shape_is_wrong():
|
||||
from employment_agent.execute_agent import parse_normalized_cities
|
||||
assert parse_normalized_cities({"oops": True}, fallback=["Karachi(Malir)"]) == ["Karachi(Malir)"]
|
||||
|
|
|
|||
|
|
@ -163,3 +163,13 @@ def test_existing_sentinels_still_normalize():
|
|||
assert fields["current_employment"] == NO_COMPANY
|
||||
assert fields["education"] == EDUCATION
|
||||
assert fields["skills"] == ["Python"]
|
||||
|
||||
|
||||
def test_city_from_the_model_is_kept_as_returned():
|
||||
resume = "Ali Khan | Karachi(Malir) | 0321-5551234"
|
||||
fields = parse_employment_response({"city": "Karachi"}, resume)
|
||||
assert fields["city"] == "Karachi"
|
||||
|
||||
|
||||
def test_city_sentinel_is_dropped():
|
||||
assert parse({"city": "no city mentioned"})["city"] is None
|
||||
|
|
|
|||
|
|
@ -96,6 +96,13 @@ class TestListAndBadgesAgree:
|
|||
assert name in params, f"count_processing should narrow on {name}"
|
||||
|
||||
|
||||
class TestCity:
|
||||
def test_agent_city_matches_raw_stored_text(self):
|
||||
out = sql(*FormData._filters(city=["Karachi"]))
|
||||
assert "ilike" in out
|
||||
assert "karachi" in out
|
||||
|
||||
|
||||
class TestPlumbing:
|
||||
@pytest.mark.parametrize(
|
||||
"func", [FormData.fetch_form_data, FormData.count_form_data],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -48,26 +48,57 @@ export function applicationStatusLabel(status, item) {
|
|||
return key.charAt(0) + key.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
function historyItemsOf(row) {
|
||||
if (!row) return []
|
||||
if (Array.isArray(row.previousApplications)) return row.previousApplications
|
||||
if (Array.isArray(row.previous_applications)) return row.previous_applications
|
||||
return []
|
||||
}
|
||||
|
||||
/** Every application for this candidate, including the one currently open. */
|
||||
export function candidateApplicationsOf(row) {
|
||||
if (!row) return []
|
||||
const current = currentRowIds(row)
|
||||
const items = [...historyItemsOf(row)]
|
||||
if (current.size && !items.some((item) => isSameApplication(item, current))) {
|
||||
const self = syntheticCurrentApplication(row)
|
||||
if (self) items.push(self)
|
||||
}
|
||||
items.sort((a, b) => (appliedAtMs(b?.applied_at) ?? 0) - (appliedAtMs(a?.applied_at) ?? 0))
|
||||
return items
|
||||
}
|
||||
|
||||
/** Applications other than the open row — used by the Reapplied chip. */
|
||||
export function previousApplicationsOf(row) {
|
||||
if (!row) return []
|
||||
const raw = Array.isArray(row.previousApplications)
|
||||
? row.previousApplications
|
||||
: Array.isArray(row.previous_applications)
|
||||
? row.previous_applications
|
||||
: []
|
||||
const current = currentRowIds(row)
|
||||
const currentTs = appliedAtMs(
|
||||
row.received || row.applied_at || row.entry_date || row.when || row.sentAt,
|
||||
)
|
||||
const items = raw.filter((item) => {
|
||||
if (current.size && isSameApplication(item, current)) return false
|
||||
const t = appliedAtMs(item?.applied_at)
|
||||
if (currentTs == null) return true
|
||||
if (t == null) return false
|
||||
return t < currentTs
|
||||
})
|
||||
items.sort((a, b) => (appliedAtMs(a?.applied_at) ?? 0) - (appliedAtMs(b?.applied_at) ?? 0))
|
||||
return items
|
||||
return candidateApplicationsOf(row).filter((item) => !isSameApplication(item, current))
|
||||
}
|
||||
|
||||
function syntheticCurrentApplication(row) {
|
||||
const assigned = row.assignedPost || row.assigned_job_post
|
||||
const position = row.kind !== 'email' && row.position && row.position !== '—' ? row.position : null
|
||||
const jobTitle = assigned?.title || row.job_title || row.jobTitle || row.currentTitle || position || null
|
||||
const kind = row.kind
|
||||
let source = row.source
|
||||
if (kind === 'form') source = 'form'
|
||||
else if (kind === 'email') source = 'inbox'
|
||||
else if (row.manualUploadId || row.manual_upload_candidate_id) source = 'manual'
|
||||
if (source && String(source).includes('@')) source = 'inbox'
|
||||
const id = row.id != null && row.id !== '' ? String(row.id) : null
|
||||
return {
|
||||
source: source || 'inbox',
|
||||
inbox_id: row.inboxId || row.inbox_id || null,
|
||||
message_id: kind === 'email' ? id : (row.message_id || null),
|
||||
form_data_id: kind === 'form' ? id : (row.form_data_id || null),
|
||||
manual_upload_candidate_id: row.manualUploadId || row.manual_upload_candidate_id || null,
|
||||
candidate_id: row.candidate_id || (kind == null && row.jobId ? row.id : null) || null,
|
||||
user_id: row.userId || row.user_id || null,
|
||||
job_post_id: row.assignedId || row.jobId || row.job_post_id || null,
|
||||
job_title: jobTitle,
|
||||
status: row.applicationStatus || row.processingState || row.status || null,
|
||||
applied_at: row.received || row.applied || row.applied_at || row.entry_date || row.when || null,
|
||||
}
|
||||
}
|
||||
|
||||
function appliedAtMs(value) {
|
||||
|
|
@ -152,7 +183,7 @@ export function hrefForPreviousApplication(item) {
|
|||
if (item.source === 'form' && item.form_data_id) {
|
||||
return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form`
|
||||
}
|
||||
if (item.source === 'inbox' && item.message_id) {
|
||||
if ((item.source === 'inbox' || item.source === 'filtered') && item.message_id) {
|
||||
return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email`
|
||||
}
|
||||
if (item.source === 'manual') {
|
||||
|
|
@ -161,13 +192,22 @@ export function hrefForPreviousApplication(item) {
|
|||
return `/matching?record=${encodeURIComponent(item.manual_upload_candidate_id)}`
|
||||
}
|
||||
}
|
||||
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}`
|
||||
if (item.form_data_id) {
|
||||
return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form`
|
||||
}
|
||||
if (item.message_id) {
|
||||
return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Full prior-job list for profile / inbox / add-candidate. */
|
||||
export function PreviousApplications({ row, title = 'Previous applications' }) {
|
||||
const items = previousApplicationsOf(row)
|
||||
/** Full application list for profile / inbox / add-candidate. */
|
||||
export function PreviousApplications({ row, title = 'Total applications' }) {
|
||||
const items = candidateApplicationsOf(row)
|
||||
if (!items.length) return null
|
||||
const current = currentRowIds(row)
|
||||
const heading = title === 'Total applications' ? `Total applications (${items.length})` : title
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
|
|
@ -188,13 +228,14 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
|||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
{heading}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, idx) => {
|
||||
const stage = applicationStatusLabel(item.status, item)
|
||||
const job = item.job_title || item.jobTitle || 'No job assigned'
|
||||
const href = hrefForPreviousApplication(item)
|
||||
const isCurrent = current.size > 0 && isSameApplication(item, current)
|
||||
const key = [
|
||||
item.source,
|
||||
item.inbox_id,
|
||||
|
|
@ -204,6 +245,12 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
|||
item.job_post_id,
|
||||
idx,
|
||||
].filter(Boolean).join(':')
|
||||
const jobLabel = (
|
||||
<>
|
||||
{job}
|
||||
{isCurrent ? ' (Current)' : ''}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
|
|
@ -215,13 +262,13 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
|||
<Link
|
||||
to={href}
|
||||
className="reapplicant-job-link"
|
||||
title="Open this previous application"
|
||||
title={isCurrent ? 'This application' : 'Open this application'}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{job}
|
||||
{jobLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="fw-600" style={{ fontSize: 13 }}>{job}</div>
|
||||
<div className="fw-600" style={{ fontSize: 13 }}>{jobLabel}</div>
|
||||
)}
|
||||
<div className="cell-sub">
|
||||
{SOURCE_LABEL[item.source] || item.source || 'Application'}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export const qk = {
|
|||
jobPosts: {
|
||||
all: () => ['jobPosts'],
|
||||
list: (p = {}) => ['jobPosts', 'list', p],
|
||||
detail: (id) => ['jobPosts', 'detail', id],
|
||||
departments: (p = {}) => ['jobPosts', 'departments', p],
|
||||
},
|
||||
jobs: {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import * as formsApi from '../api/forms'
|
|||
import * as pipelineApi from '../api/pipeline'
|
||||
import * as s3Api from '../api/s3'
|
||||
import CandidateFormsTab from './CandidateForms'
|
||||
import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory'
|
||||
import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory'
|
||||
import { fmtDate, fmtTime, toDate } from '../lib/format'
|
||||
import { companies, moneyK, pick } from '../data/seed'
|
||||
|
||||
|
|
@ -361,7 +361,7 @@ export default function CandidateProfile({
|
|||
<span className="cell-sub">{rating ? `${rating.toFixed(1)} / 5.0` : 'Not rated'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Info label="Applications" val={live.job_posts?.length || 0} />
|
||||
<Info label="Applications" val={candidateApplicationsOf(live).length} />
|
||||
</div>
|
||||
|
||||
{(live.match_summary || live.match_reasoning) && (
|
||||
|
|
|
|||
|
|
@ -105,65 +105,144 @@ function fmtWhen(iso) {
|
|||
return fmtShort(iso) || '—'
|
||||
}
|
||||
|
||||
const PERIOD_SHEET_NAMES = {
|
||||
week: 'Weekly',
|
||||
month: 'Monthly',
|
||||
quarter: 'Quarterly',
|
||||
year: 'Yearly',
|
||||
function dashExportValue(value) {
|
||||
if (value == null || value === '') return ''
|
||||
return value
|
||||
}
|
||||
|
||||
const PERIOD_METRICS = [
|
||||
{ label: 'Open Jobs', key: 'open_jobs' },
|
||||
{ label: 'Applications', key: 'total_candidates' },
|
||||
{ label: 'Hires', key: 'hires' },
|
||||
{ label: 'Offers Sent', key: 'offers_sent' },
|
||||
{ label: 'Offers Accepted', key: 'offers_accepted' },
|
||||
{ label: 'Cost per Hire', key: 'cost_per_hire', round: true },
|
||||
]
|
||||
|
||||
function kpiValue(kpis, key, { round = false } = {}) {
|
||||
if (!kpis || kpis[key] == null || kpis[key] === '') return ''
|
||||
const n = Number(kpis[key])
|
||||
if (!Number.isFinite(n)) return kpis[key]
|
||||
return round ? Math.round(n) : n
|
||||
}
|
||||
|
||||
async function fetchRangeSnapshot(rangeKey, department) {
|
||||
const span = rangeWindow(rangeKey)
|
||||
const kpisRes = await analyticsApi.kpis({
|
||||
...span,
|
||||
department: department || undefined,
|
||||
})
|
||||
return {
|
||||
key: rangeKey,
|
||||
fromDate: span.fromDate,
|
||||
toDate: span.toDate,
|
||||
kpis: asObject(kpisRes?.data),
|
||||
}
|
||||
}
|
||||
|
||||
function buildPeriodSheets({ department, snapshots, exportedAt }) {
|
||||
function buildDashboardSheets({
|
||||
department,
|
||||
rangeKey,
|
||||
span,
|
||||
exportedAt,
|
||||
kpis,
|
||||
jobApps,
|
||||
pipeRows,
|
||||
offerCounts,
|
||||
attentionRows,
|
||||
starvingCount,
|
||||
activity,
|
||||
}) {
|
||||
const deptLabel = department || 'All Departments'
|
||||
const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10)
|
||||
const columns = [
|
||||
{ header: 'Metric', key: 'metric', width: 22 },
|
||||
{ header: 'Value', key: 'value', width: 16 },
|
||||
const from = fmtDate(span.fromDate) || span.fromDate
|
||||
const to = fmtDate(span.toDate) || span.toDate
|
||||
const subtitle = `${rangeLabel(rangeKey)} · ${from} – ${to} · ${deptLabel} · exported ${stamp}`
|
||||
const k = kpis || {}
|
||||
|
||||
const summaryRows = [
|
||||
{ metric: 'Open Jobs', value: dashExportValue(k.open_jobs), trend: pctDelta(k.open_jobs, k.open_jobs_prior) || '' },
|
||||
{ metric: 'Applications', value: dashExportValue(k.total_candidates), trend: pctDelta(k.total_candidates, k.total_candidates_prior) || '' },
|
||||
{ metric: 'Hires', value: dashExportValue(k.hires), trend: pctDelta(k.hires, k.hires_prior) || '' },
|
||||
{ metric: 'Offers Sent', value: dashExportValue(k.offers_sent), trend: pctDelta(k.offers_sent, k.offers_sent_prior) || '' },
|
||||
{ metric: 'Offers Accepted', value: dashExportValue(k.offers_accepted), trend: pctDelta(k.offers_accepted, k.offers_accepted_prior) || '' },
|
||||
{
|
||||
metric: 'Interviews Today',
|
||||
value: dashExportValue(k.interviews_today),
|
||||
trend: k.interviews_upcoming != null ? `${k.interviews_upcoming} upcoming` : '',
|
||||
},
|
||||
{
|
||||
metric: 'Time to Hire',
|
||||
value: k.time_to_hire != null ? `${Math.round(k.time_to_hire)} days` : '',
|
||||
trend: dayDelta(k.time_to_hire, k.time_to_hire_prior) || '',
|
||||
},
|
||||
{
|
||||
metric: 'Cost per Hire',
|
||||
value: k.cost_per_hire != null ? money(Math.round(k.cost_per_hire)) : '',
|
||||
trend: pctDelta(k.cost_per_hire, k.cost_per_hire_prior) || '',
|
||||
},
|
||||
]
|
||||
return snapshots.map((s) => {
|
||||
const name = PERIOD_SHEET_NAMES[s.key] || s.key
|
||||
const from = fmtDate(s.fromDate) || s.fromDate
|
||||
const to = fmtDate(s.toDate) || s.toDate
|
||||
return {
|
||||
name,
|
||||
title: name,
|
||||
subtitle: `${from} – ${to} · ${deptLabel} · exported ${stamp}`,
|
||||
columns,
|
||||
rows: PERIOD_METRICS.map((spec) => ({
|
||||
metric: spec.label,
|
||||
value: kpiValue(s.kpis, spec.key, { round: spec.round }),
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'Summary',
|
||||
title: 'Dashboard',
|
||||
subtitle,
|
||||
columns: [
|
||||
{ header: 'Metric', key: 'metric', width: 22 },
|
||||
{ header: 'Value', key: 'value', width: 16 },
|
||||
{ header: 'Vs prior period', key: 'trend', width: 18 },
|
||||
],
|
||||
rows: summaryRows,
|
||||
},
|
||||
{
|
||||
name: 'Applications per Job',
|
||||
title: 'Applications per Job',
|
||||
subtitle,
|
||||
columns: [
|
||||
{ header: 'Job', key: 'title', width: 36 },
|
||||
{ header: 'Department', key: 'department', width: 22 },
|
||||
{ header: 'Status', key: 'status', width: 14 },
|
||||
{ header: 'Vacancies', key: 'vacancies', width: 12 },
|
||||
{ header: 'Applications', key: 'count', width: 14 },
|
||||
],
|
||||
rows: jobApps.map((j) => ({
|
||||
title: j.title || '',
|
||||
department: j.department || '',
|
||||
status: jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status || '',
|
||||
vacancies: j.vacancies ?? '',
|
||||
count: j.count ?? 0,
|
||||
})),
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'Pipeline',
|
||||
title: 'Candidate Pipeline',
|
||||
subtitle,
|
||||
columns: [
|
||||
{ header: 'Stage', key: 'stage', width: 18 },
|
||||
{ header: 'Count', key: 'count', width: 12 },
|
||||
{ header: 'Share', key: 'share', width: 12 },
|
||||
],
|
||||
rows: pipeRows.map((r) => ({
|
||||
stage: r.stage,
|
||||
count: r.count,
|
||||
share: r.pct != null ? `${r.pct}%` : '',
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'Offer Book',
|
||||
title: 'Offer Book',
|
||||
subtitle: `All offers by status · point-in-time · exported ${stamp}`,
|
||||
columns: [
|
||||
{ header: 'Status', key: 'status', width: 18 },
|
||||
{ header: 'Count', key: 'count', width: 12 },
|
||||
],
|
||||
rows: offersApi.OFFER_STATUSES.map((s) => ({
|
||||
status: offersApi.OFFER_STATUS_LABEL[s],
|
||||
count: offerCounts[s] ?? 0,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'Needs Attention',
|
||||
title: 'Needs Attention',
|
||||
subtitle: `Work queued right now · exported ${stamp}`,
|
||||
columns: [
|
||||
{ header: 'Item', key: 'title', width: 36 },
|
||||
{ header: 'Count', key: 'count', width: 12 },
|
||||
],
|
||||
rows: [
|
||||
...attentionRows.map((row) => ({ title: row.title, count: row.count ?? 0 })),
|
||||
{ title: 'Open jobs with no applications', count: starvingCount },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Recent Activity',
|
||||
title: 'Recent Activity',
|
||||
subtitle: `Latest across the org · exported ${stamp}`,
|
||||
columns: [
|
||||
{ header: 'When', key: 'when', width: 20 },
|
||||
{ header: 'Activity', key: 'activity', width: 28 },
|
||||
{ header: 'Actor', key: 'actor', width: 22 },
|
||||
{ header: 'Detail', key: 'detail', width: 40 },
|
||||
],
|
||||
rows: activity.map((a) => ({
|
||||
when: fmtWhen(a.activity_date),
|
||||
activity: a.activity_type || 'Activity',
|
||||
actor: a.actor_name || '',
|
||||
detail: a.description || a.activity_status || '',
|
||||
})),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
|
||||
|
|
@ -423,16 +502,54 @@ function DashboardHome() {
|
|||
if (exporting) return
|
||||
setExporting(true)
|
||||
try {
|
||||
const snapshots = await Promise.all(
|
||||
RANGES.map((r) => fetchRangeSnapshot(r.key, department)),
|
||||
)
|
||||
const [kpisRes, jobAppsRes, funnelRes, offersRes, countsRes, activityRes] = await Promise.all([
|
||||
analyticsApi.kpis(filters),
|
||||
analyticsApi.applicationsPerJob({ top: JOBS_FETCHED, ...filters }),
|
||||
analyticsApi.funnel(filters),
|
||||
offersApi.list({ top: 500 }).catch(() => null),
|
||||
inboxApi.fetchCounts().catch(() => ({})),
|
||||
activityApi.feed({ top: 8 }).catch(() => null),
|
||||
])
|
||||
const kpis = asObject(kpisRes?.data)
|
||||
const jobApps = asList(jobAppsRes?.data)
|
||||
const funnel = asList(funnelRes?.data)
|
||||
const exportPipeRows = (() => {
|
||||
const rows = analyticsApi
|
||||
.toBoardStageRows(funnel, { includeRejected: true })
|
||||
.filter((r) => r.count > 0)
|
||||
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
pct: total ? Math.round((r.count / total) * 100) : 0,
|
||||
}))
|
||||
})()
|
||||
const offers = asList(offersRes?.data)
|
||||
const exportOfferCounts = Object.fromEntries(offersApi.OFFER_STATUSES.map((s) => [s, 0]))
|
||||
for (const o of offers) {
|
||||
if (o.status in exportOfferCounts) exportOfferCounts[o.status] += 1
|
||||
}
|
||||
const counts = countsRes && typeof countsRes === 'object' ? countsRes : {}
|
||||
const exportAttention = [
|
||||
{ title: 'Unread applications', count: counts.unread },
|
||||
{ title: 'Not assigned to a job', count: counts.unassigned },
|
||||
{ title: 'Flagged duplicates', count: counts.duplicates },
|
||||
]
|
||||
const exportStarving = jobApps.filter((r) => r.requisition_status === 'open' && !r.count).length
|
||||
const deptSlug = (department || 'all').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'all'
|
||||
await exportStyledWorkbook({
|
||||
filename: `dashboard-${deptSlug}-${new Date().toISOString().slice(0, 10)}`,
|
||||
sheets: buildPeriodSheets({
|
||||
sheets: buildDashboardSheets({
|
||||
department,
|
||||
snapshots,
|
||||
rangeKey,
|
||||
span,
|
||||
exportedAt: new Date(),
|
||||
kpis,
|
||||
jobApps,
|
||||
pipeRows: exportPipeRows,
|
||||
offerCounts: exportOfferCounts,
|
||||
attentionRows: exportAttention,
|
||||
starvingCount: exportStarving,
|
||||
activity: asList(activityRes?.data),
|
||||
}),
|
||||
})
|
||||
toast('Dashboard exported to Excel', 'success')
|
||||
|
|
|
|||
|
|
@ -68,11 +68,65 @@ const LIST_CACHE = {
|
|||
}
|
||||
const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS }
|
||||
|
||||
/**
|
||||
* Last 10 opened applicant details. The queue list is a light row; GET-by-id
|
||||
* is the body, suggested roles, resume text. Re-opening one of these should
|
||||
* not pay that trip again for 15 minutes. Cap is LRU — the 11th open drops
|
||||
* the oldest. After the TTL the snapshot is dropped so the next open refetches.
|
||||
* Mutations that write THIS row still invalidate its key.
|
||||
*/
|
||||
const OPENED_DETAIL_CAP = 10
|
||||
const OPENED_DETAIL_TTL_MS = 15 * 60_000
|
||||
const openedDetailLru = []
|
||||
|
||||
function detailQueryKey(kind, id) {
|
||||
return kind === 'form' ? qk.mailbox.formRow(id) : qk.mailbox.message(id)
|
||||
}
|
||||
|
||||
function pruneOpenedDetailLru(qc, keepSig) {
|
||||
const cutoff = Date.now() - OPENED_DETAIL_TTL_MS
|
||||
const next = []
|
||||
for (const x of openedDetailLru) {
|
||||
if (x.at > cutoff || x.sig === keepSig) {
|
||||
next.push(x)
|
||||
continue
|
||||
}
|
||||
qc.removeQueries({ queryKey: x.key })
|
||||
}
|
||||
openedDetailLru.length = 0
|
||||
openedDetailLru.push(...next)
|
||||
}
|
||||
|
||||
function rememberOpenedDetail(qc, kind, id) {
|
||||
if (!id) return
|
||||
const sig = `${kind}:${id}`
|
||||
const key = detailQueryKey(kind, id)
|
||||
pruneOpenedDetailLru(qc, sig)
|
||||
const next = openedDetailLru.filter((x) => x.sig !== sig)
|
||||
next.push({ sig, kind, id, key, at: Date.now() })
|
||||
while (next.length > OPENED_DETAIL_CAP) {
|
||||
const dropped = next.shift()
|
||||
if (dropped && dropped.sig !== sig) {
|
||||
qc.removeQueries({ queryKey: dropped.key })
|
||||
}
|
||||
}
|
||||
openedDetailLru.length = 0
|
||||
openedDetailLru.push(...next)
|
||||
}
|
||||
|
||||
const DETAIL_CACHE = {
|
||||
staleTime: OPENED_DETAIL_TTL_MS,
|
||||
gcTime: OPENED_DETAIL_TTL_MS,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
}
|
||||
|
||||
/** Typing must not put a query key (and a skeleton) on screen per keystroke. */
|
||||
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 +308,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 +563,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 +608,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 +621,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,21 +965,24 @@ 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
|
||||
* than a refactor of theirs.)
|
||||
*/
|
||||
/** Junk location tokens — hide from the dropdown, not from the list query. */
|
||||
const HIDDEN_LOCATION = /^(KA|KAR|KARA|WAH)$/i
|
||||
const HIDDEN_LOCATION = /^(KA|KAR|KARA)$/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 +1001,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 +1016,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 +1164,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 +1195,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 +1209,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 +1233,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 +1296,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 +1414,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
|
||||
|
|
@ -1316,7 +1442,16 @@ export default function Inbox() {
|
|||
queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
|
||||
queryFn: () => (selectedKind === 'form' ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)),
|
||||
enabled: Boolean(selectedId),
|
||||
...DETAIL_CACHE,
|
||||
})
|
||||
useEffect(() => {
|
||||
pruneOpenedDetailLru(qc, selectedId ? `${selectedKind}:${selectedId}` : null)
|
||||
if (!selectedId || !detailQuery.isSuccess || !detailQuery.data) return
|
||||
rememberOpenedDetail(qc, selectedKind, selectedId)
|
||||
}, [qc, selectedId, selectedKind, detailQuery.isSuccess, detailQuery.data])
|
||||
// List rows paint immediately; GET-by-id is the full application. Block every
|
||||
// control in the detail pane until that fetch succeeds. The queue stays live.
|
||||
const detailLocked = Boolean(selectedId) && !detailQuery.isError && !detailQuery.isSuccess
|
||||
|
||||
const sidebar = useMemo(() => {
|
||||
if (!isForms) return list
|
||||
|
|
@ -1336,6 +1471,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 +1521,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 +1548,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 +1848,7 @@ export default function Inbox() {
|
|||
<InboxFilters
|
||||
filters={inboxFilters}
|
||||
cities={cities}
|
||||
sources={sources}
|
||||
showLinkFilters={isForms}
|
||||
active={activeInboxFilters}
|
||||
open={showInboxFilters}
|
||||
|
|
@ -1874,32 +2022,40 @@ export default function Inbox() {
|
|||
{friendlyAuthError(detailQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : selected?.kind === 'form' ? (
|
||||
<FormApplicantDetail
|
||||
item={selected}
|
||||
loading={detailQuery.isPending}
|
||||
busy={setState.isPending || markDuplicate.isPending}
|
||||
canEdit={canEdit}
|
||||
toast={toast}
|
||||
onImport={() => importItem(selected)}
|
||||
onMove={() => moveToPipeline(selected)}
|
||||
onNote={() => setNoting(selected)}
|
||||
onReject={() => reject(selected)}
|
||||
onToggleDuplicate={() => toggleDuplicate(selected)}
|
||||
/>
|
||||
) : (
|
||||
<ApplicationDetail
|
||||
item={selected}
|
||||
loading={detailQuery.isPending}
|
||||
busy={setState.isPending || markDuplicate.isPending}
|
||||
canEdit={canEdit}
|
||||
toast={toast}
|
||||
onImport={() => importItem(selected)}
|
||||
onMove={() => moveToPipeline(selected)}
|
||||
onNote={() => setNoting(selected)}
|
||||
onReject={() => reject(selected)}
|
||||
onToggleDuplicate={() => toggleDuplicate(selected)}
|
||||
/>
|
||||
<div
|
||||
className={detailLocked ? 'inbox-detail-pending' : undefined}
|
||||
inert={detailLocked || undefined}
|
||||
aria-busy={detailLocked || undefined}
|
||||
>
|
||||
{selected?.kind === 'form' ? (
|
||||
<FormApplicantDetail
|
||||
item={selected}
|
||||
loading={detailLocked}
|
||||
busy={detailLocked || setState.isPending || markDuplicate.isPending}
|
||||
canEdit={!detailLocked && canEdit}
|
||||
toast={toast}
|
||||
onImport={() => importItem(selected)}
|
||||
onMove={() => moveToPipeline(selected)}
|
||||
onNote={() => setNoting(selected)}
|
||||
onReject={() => reject(selected)}
|
||||
onToggleDuplicate={() => toggleDuplicate(selected)}
|
||||
/>
|
||||
) : (
|
||||
<ApplicationDetail
|
||||
item={selected}
|
||||
loading={detailLocked}
|
||||
busy={detailLocked || setState.isPending || markDuplicate.isPending}
|
||||
canEdit={!detailLocked && canEdit}
|
||||
toast={toast}
|
||||
onImport={() => importItem(selected)}
|
||||
onMove={() => moveToPipeline(selected)}
|
||||
onNote={() => setNoting(selected)}
|
||||
onReject={() => reject(selected)}
|
||||
onToggleDuplicate={() => toggleDuplicate(selected)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2243,6 +2399,7 @@ function FormApplicantDetail({
|
|||
badge={`Match #${rank}`}
|
||||
selected={String(selection) === String(post.id)}
|
||||
onSelect={(id) => setSelection(String(id))}
|
||||
lazy
|
||||
/>
|
||||
))}
|
||||
{manualPost && (
|
||||
|
|
@ -2252,6 +2409,7 @@ function FormApplicantDetail({
|
|||
manual
|
||||
selected={String(selection) === String(manualPost.id)}
|
||||
onSelect={(id) => setSelection(String(id))}
|
||||
lazy
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -2467,7 +2625,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>
|
||||
|
|
@ -2650,6 +2808,7 @@ function ApplicationDetail({
|
|||
selected={String(selection) === String(post.id)}
|
||||
onSelect={(id) => setSelection(String(id))}
|
||||
resumeText={resumeText}
|
||||
lazy
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
@ -2661,6 +2820,7 @@ function ApplicationDetail({
|
|||
selected={String(selection) === String(manualPost.id)}
|
||||
onSelect={(id) => setSelection(String(id))}
|
||||
resumeText={resumeText}
|
||||
lazy
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1032,6 +1032,14 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.inbox-split { grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); }
|
||||
/* Desktop keeps the two-pane split; only the phone layout below shows it. */
|
||||
.inbox-back { display: none; }
|
||||
/* Detail pane is painted from the list row before GET-by-id returns. Block
|
||||
every control inside it until that payload lands — the queue list stays live. */
|
||||
.inbox-detail-pending {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
/* Phones: the stacked split nested two scroll wells inside the page scroll.
|
||||
Instead: one page scroll, and a selected application takes over from the
|
||||
list — the Back button returns to it (standard master-detail collapse). */
|
||||
|
|
|
|||
|
|
@ -30,25 +30,48 @@ function reqLabel(req) {
|
|||
return String(req)
|
||||
}
|
||||
|
||||
export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge }) {
|
||||
export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge, lazy = false }) {
|
||||
const unavailable = Boolean(post?.unavailable) || !post?.title
|
||||
const title = post?.title || 'Unavailable'
|
||||
const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`)
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasBody = Boolean(
|
||||
post?.location || post?.employment_type || post?.description
|
||||
|| (Array.isArray(post?.requirements) && post.requirements.length),
|
||||
)
|
||||
const detailQuery = useQuery({
|
||||
queryKey: qk.jobPosts.detail(post?.id),
|
||||
queryFn: async () => {
|
||||
const res = await jobPostsApi.list({ ids: [post.id], activeOnly: false })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows[0] || null
|
||||
},
|
||||
enabled: Boolean(lazy && open && post?.id && !unavailable && !hasBody),
|
||||
staleTime: 10 * 60_000,
|
||||
gcTime: 60 * 60_000,
|
||||
})
|
||||
const body = (!lazy || hasBody) ? post : (detailQuery.data || post)
|
||||
const meta = [
|
||||
post?.employment_type,
|
||||
post?.location,
|
||||
post?.experience_min != null || post?.experience_max != null
|
||||
? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs`
|
||||
body?.employment_type,
|
||||
body?.location,
|
||||
body?.experience_min != null || body?.experience_max != null
|
||||
? `${body?.experience_min ?? '?'}–${body?.experience_max ?? '?'} yrs`
|
||||
: null,
|
||||
].filter(Boolean).join(' · ')
|
||||
const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`)
|
||||
const showBody = !lazy || open
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-expanded={lazy ? open : undefined}
|
||||
tabIndex={0}
|
||||
className="list-row"
|
||||
onClick={() => !unavailable && onSelect(post.id)}
|
||||
onClick={() => {
|
||||
if (unavailable) return
|
||||
onSelect(post.id)
|
||||
if (lazy) setOpen(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (unavailable) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
|
@ -67,6 +90,20 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
|
|||
>
|
||||
<div className="lr-main" style={{ minWidth: 0 }}>
|
||||
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
|
||||
{lazy && !unavailable && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
aria-label={open ? 'Hide role details' : 'Show role details'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen((v) => !v)
|
||||
}}
|
||||
style={{ padding: 4, minWidth: 28 }}
|
||||
>
|
||||
<Icon name={open ? 'chevron-down' : 'chevron-right'} />
|
||||
</button>
|
||||
)}
|
||||
<span className="tag">{tag}</span>
|
||||
<div className="lr-title">{title}</div>
|
||||
{unavailable ? (
|
||||
|
|
@ -77,10 +114,16 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
|
|||
{post?.overall_score != null && <ScoreChip score={post.overall_score} />}
|
||||
{selected && <Icon name="check-circle" />}
|
||||
</div>
|
||||
{meta && <div className="cell-sub">{meta}</div>}
|
||||
{!unavailable && Array.isArray(post.requirements) && post.requirements.length > 0 && (
|
||||
{showBody && detailQuery.isPending && lazy && !hasBody && (
|
||||
<div className="cell-sub">Loading role…</div>
|
||||
)}
|
||||
{showBody && detailQuery.isError && lazy && (
|
||||
<div className="cell-sub">{friendlyAuthError(detailQuery.error, 'Could not load this role.')}</div>
|
||||
)}
|
||||
{showBody && meta && <div className="cell-sub">{meta}</div>}
|
||||
{showBody && !unavailable && Array.isArray(body.requirements) && body.requirements.length > 0 && (
|
||||
<div className="k-tags" style={{ marginTop: 8 }}>
|
||||
{post.requirements.slice(0, 8).map((req, i) => {
|
||||
{body.requirements.slice(0, 8).map((req, i) => {
|
||||
const label = reqLabel(req)
|
||||
if (!label) return null
|
||||
const hit = reqInResume(req, resumeText)
|
||||
|
|
|
|||
Loading…
Reference in New Issue