From 3dd716c3d3533bd507c4713f8940058be524d700 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 7 Sep 2026 17:32:20 +0500 Subject: [PATCH 1/3] added onhold tab that sorts the unmatched cvs --- backend/employment_agent/decorators.py | 22 +- backend/employment_agent/execute_agent.py | 1 + backend/employment_agent/prompt.py | 25 ++- backend/g_sheet/app.py | 12 ++ backend/g_sheet/enums.py | 2 + backend/g_sheet/models.py | 94 +++++++- backend/g_sheet/views.py | 19 +- backend/inbox/app.py | 40 +++- backend/inbox/models.py | 103 ++++++++- backend/inbox/serializers.py | 1 + backend/inbox/tasks.py | 2 + backend/inbox/views.py | 144 ++++++++++++- backend/job/app.py | 2 + backend/job/candidate/models.py | 44 +++- backend/job/candidate/views.py | 7 +- .../manual/031_candidate_city_reapplied.sql | 40 ++++ backend/users/models.py | 49 ++++- frontend/src/api/inbox.js | 12 +- frontend/src/api/sheet.js | 8 +- frontend/src/lib/queryKeys.js | 1 + frontend/src/screens/Inbox.jsx | 200 ++++++++++++------ 21 files changed, 710 insertions(+), 118 deletions(-) create mode 100644 backend/migrations/manual/031_candidate_city_reapplied.sql diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py index 614a7d3..49128ae 100644 --- a/backend/employment_agent/decorators.py +++ b/backend/employment_agent/decorators.py @@ -17,7 +17,7 @@ from __future__ import annotations import re from functools import wraps -from employment_agent.prompt import EDUCATION,NO_COMPANY,NO_LINKEDIN,NO_PHONE +from employment_agent.prompt import EDUCATION,NO_CITY,NO_COMPANY,NO_LINKEDIN,NO_PHONE def require_json_object(func): @@ -102,6 +102,21 @@ def _clean_phone(value,resume_text): return text +def _clean_city(value,resume_text): + """Optional residence city. Sentinel / invented → 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. + """ + 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 + + def _clean_skills(value,resume_text): """Keep only skills the resume actually contains, deduplicated, capped at 30. @@ -171,6 +186,7 @@ clamp_linkedin_url=clamp_field("linkedin_url",_clean_linkedin) clamp_phone=clamp_field("phone",_clean_phone) clamp_skills=clamp_field("skills",_clean_skills) clamp_years_experience=clamp_field("years_experience",_clean_years) +clamp_city=clamp_field("city",_clean_city) @require_json_object @@ -181,8 +197,9 @@ clamp_years_experience=clamp_field("years_experience",_clean_years) @clamp_phone @clamp_skills @clamp_years_experience +@clamp_city def parse_employment_response(data,resume_text=""): - """Pull company, education, title, linkedin_url, phone, skills, and years + """Pull company, education, title, linkedin_url, phone, city, skills, and years from the agent JSON. skills and years_experience default to []/None when the key is absent, so a @@ -198,6 +215,7 @@ def parse_employment_response(data,resume_text=""): "current_title":as_str("current_title"), "linkedin_url":as_str("linkedin_url"), "phone":as_str("phone"), + "city":as_str("city"), "skills":data.get("skills") if isinstance(data.get("skills"),list) else [], "years_experience":data.get("years_experience"), } diff --git a/backend/employment_agent/execute_agent.py b/backend/employment_agent/execute_agent.py index debac70..b240284 100644 --- a/backend/employment_agent/execute_agent.py +++ b/backend/employment_agent/execute_agent.py @@ -24,6 +24,7 @@ async def run_employment_agent(*,resume_text=""): "current_title":CURRENT_TITLE, "linkedin_url":None, "phone":None, + "city":None, "skills":[], "years_experience":None, } diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py index 070367f..aa7ffb1 100644 --- a/backend/employment_agent/prompt.py +++ b/backend/employment_agent/prompt.py @@ -12,6 +12,7 @@ EDUCATION="No Education Mentioned" CURRENT_TITLE="No JOB POSITION MENTIONED" NO_LINKEDIN="no linkedin url mentioned" NO_PHONE="no phone number mentioned" +NO_CITY="no city mentioned" def prompt(): @@ -19,8 +20,8 @@ def prompt(): You are given CV/resume text. Identify the candidate's CURRENT employer company name, their education (degree / school), their current job title, their -LinkedIn profile URL, their phone number, their skills, and their total years -of professional experience, when present. +LinkedIn profile URL, their phone number, their city of residence, their skills, +and their total years of professional experience, when present. Rules: - Return only the company name that appears in the resume text for the ongoing / most recent role. @@ -55,6 +56,13 @@ linkedin_url (its own key — extract this separately from the other fields): - Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN} - Never guess a slug or construct linkedin.com/in/ 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. +- 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. - Pakistani mobiles are 11 digits local (03XX-XXXXXXX / 03XX XXXXXXX) or +92 3XX XXXXXXX (12 digits with country code). Copy the last group in full — never stop after 7 or 8 digits. @@ -65,7 +73,7 @@ phone (its own key — extract this separately; copy EVERY digit): Examples of CORRECT values (copy this completeness; these are format samples, not this candidate): Example 1 — local 11-digit PK mobile, full LinkedIn: -Resume: "Ali Khan | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer | Skills: Python, Django, PostgreSQL | 6 years of experience" +Resume: "Ali Khan | Karachi | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer | Skills: Python, Django, PostgreSQL | 6 years of experience" JSON: {{ "current_employment": "Acme", @@ -73,6 +81,7 @@ JSON: "current_title": "Engineer", "linkedin_url": "https://www.linkedin.com/in/ali-khan", "phone": "0321-5551234", + "city": "Karachi", "skills": ["Python", "Django", "PostgreSQL"], "years_experience": 6 }} @@ -101,6 +110,14 @@ Example 7 — dates only: Resume: "Acme, Jan 2018 - Jan 2024, Engineer" JSON years_experience must be 6, and skills must be [] because none are listed. +Example 8 — work-experience cities are NOT residence: +Resume: "Ali Khan | 0321-5551234\\nExperience: Acme, Karachi, 2019-2021; Globex, UAE, 2022-2024; Contoso, USA, 2024-present" +JSON city must be exactly: {NO_CITY}. Do not return Karachi, UAE, USA, or any other job-site city. + +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". + Respond with JSON only: {{ "current_employment": "Company Name", @@ -108,9 +125,11 @@ Respond with JSON only: "current_title": "Job Title", "linkedin_url": "https://www.linkedin.com/in/slug", "phone": "+92 300 1234567", + "city": "Lahore", "skills": ["Skill One", "Skill Two"], "years_experience": 5 }} +If the contact/location section has no city, city must be "{NO_CITY}" — still return the rest of the JSON. Never omit the candidate because city is blank. """ diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index a181ff1..583b479 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -18,6 +18,13 @@ load_dotenv() router = APIRouter() +def _city_values(city: str | None): + if not city or not str(city).strip(): + return None + parts=[p.strip() for p in str(city).split(",") if p.strip()] + return parts or None + + class AppendRowsBody(BaseModel): rows: list[list[str]] @@ -201,6 +208,8 @@ async def fetch_form_data( # 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), + 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), @@ -213,6 +222,7 @@ 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, ) return JSONResponse(content={"data":items,"total":total,"status_code":200}) except HTTPException: @@ -231,6 +241,7 @@ async def fetch_form_data_counts( search: str | None = Query(None), has_linkedin: bool | None = Query(None), has_resume: bool | None = Query(None), + city: str | None = Query(None), current_user: dict = Depends(_FORM_DATA_READ), session: AsyncSession = Depends(get_session), ): @@ -238,6 +249,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), ) return JSONResponse(content={"data":data,"total":1,"status_code":200}) except HTTPException: diff --git a/backend/g_sheet/enums.py b/backend/g_sheet/enums.py index 3918bf5..55e3f46 100644 --- a/backend/g_sheet/enums.py +++ b/backend/g_sheet/enums.py @@ -206,6 +206,7 @@ class FormDataColumn(str, Enum): EXPERIENCE_DETAILS = "experience_details" AREA_OF_RESIDENCE = "area_of_residence" RESIDING_CITY = "residing_city" + CITY = "city" RESIDING_COUNTRY = "residing_country" COMMUNICATION_SKILLS = "communication_skills" PREFERRED_TIMINGS = "preferred_timings" @@ -223,6 +224,7 @@ class FormDataColumn(str, Enum): # Same vocabulary as inbox_messages — Import / Shortlist / Reject / Duplicate. PROCESSING_STATE = "processing_state" IS_DUPLICATE = "is_duplicate" + REAPPLIED = "reapplied" RAW_RECORD = "raw_record" IMPORTED_AT = "imported_at" CREATED_AT = "created_at" diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index 1be1a5d..b3d4451 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -5,7 +5,7 @@ from __future__ import annotations import uuid from datetime import datetime, timezone -from sqlalchemy import Column, DateTime, Index, and_, case, delete, func, insert, or_ +from sqlalchemy import Column, DateTime, Index, and_, case, delete, func, insert, or_, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, SQLModel, select @@ -85,6 +85,8 @@ class FormData(SQLModel, table=True): area_of_residence: str | None = Field(default=None) residing_city: str | None = Field(default=None) residing_country: str | None = Field(default=None) + city: str | None = Field(default=None) + reapplied: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"}) communication_skills: int | None = Field(default=None) preferred_timings: str | None = Field(default=None) ho_availability: str | None = Field(default=None) @@ -109,10 +111,25 @@ class FormData(SQLModel, table=True): created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + @classmethod + def _no_suggested_jobs(cls): + """True when suggested_job_post_ids is missing, not an array, or []. + + jsonb_array_length() raises on scalar JSONB. CASE evaluates WHEN arms + in order, so length is only read after jsonb_typeof confirms an array. + """ + typeof = func.jsonb_typeof(cls.suggested_job_post_ids) + return case( + (cls.suggested_job_post_ids.is_(None), True), + (typeof != "array", True), + (func.jsonb_array_length(cls.suggested_job_post_ids) == 0, True), + else_=False, + ) + @classmethod def _filters( cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None, - has_linkedin=None, has_resume=None, + has_linkedin=None, has_resume=None, city=None, no_suggestions=None, inbox_filter=None, ): filters = [] if sheet: @@ -140,6 +157,21 @@ class FormData(SQLModel, table=True): filters.append( cls.resume_link.is_not(None) if has_resume else cls.resume_link.is_(None) ) + 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])) + if no_suggestions is True: + filters.append(cls._no_suggested_jobs()) + if inbox_filter == "matched": + filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None))) + elif inbox_filter == "unassigned": + filters.append(cls.assigned_job_post_id.is_(None)) + filters.append(cls.job_post_id.is_(None)) + elif inbox_filter == "rejected": + filters.append(cls.processing_state == "rejected") + elif inbox_filter == "duplicate": + filters.append(cls.is_duplicate == True) # noqa: E712 if search: # Twelve unanchored ILIKEs over ~26k rows is a sequential scan of a few # tens of ms — acceptable at this size; a pg_trgm GIN index is the @@ -160,6 +192,7 @@ class FormData(SQLModel, table=True): cls.source_of_application.ilike(pattern), cls.cnic.ilike(pattern), cls.residing_city.ilike(pattern), + cls.city.ilike(pattern), )) return filters @@ -313,13 +346,14 @@ 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, offset=0, limit=None, + has_resume=None, city=None, no_suggestions=None, inbox_filter=None, offset=0, limit=None, ): statement = select(cls).order_by(cls.sheet, cls.row_number) 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, ): statement = statement.where(clause) if offset: @@ -368,17 +402,53 @@ class FormData(SQLModel, table=True): }) return rows + @classmethod + async def job_post_ids_by_emails(cls, session: AsyncSession, emails): + """(email, job_post_id) pairs from assigned or job_post_id. Unlinked skipped.""" + lowers=sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + if not lowers: + return [] + result=await session.execute( + select(cls.candidate_email,cls.job_post_id,cls.assigned_job_post_id) + .where(func.lower(cls.candidate_email).in_(lowers)) + ) + rows=[] + for email,job_id,assigned_id in result.all(): + key=(email or "").strip().lower() + if assigned_id is not None: + rows.append((key,str(assigned_id))) + if job_id is not None and job_id!=assigned_id: + rows.append((key,str(job_id))) + return rows + + @classmethod + async def set_reapplied_by_emails(cls, session: AsyncSession, mapping): + if not mapping: + return 0 + updated=0 + for email, ids in mapping.items(): + key=(email or "").strip().lower() + if not key: + continue + result=await session.execute( + update(cls).where(func.lower(cls.candidate_email)==key).values(reapplied=list(ids or [])) + ) + updated+=result.rowcount or 0 + await session.commit() + return updated + @classmethod async def count_form_data( cls, session: AsyncSession, *, sheet=None, search=None, processing_state=None, is_duplicate=None, has_linkedin=None, - has_resume=None, + has_resume=None, city=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, ): statement = statement.where(clause) result = await session.execute(statement) @@ -387,7 +457,7 @@ class FormData(SQLModel, table=True): @classmethod async def count_processing( cls, session: AsyncSession, *, sheet=None, search=None, - has_linkedin=None, has_resume=None, + has_linkedin=None, has_resume=None, city=None, ): """Tab badge counts for the Sheet Forms channel. @@ -408,10 +478,11 @@ class FormData(SQLModel, table=True): func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"), func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"), func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712 + func.coalesce(func.sum(case((cls._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"), ).select_from(cls) for clause in cls._filters( sheet=sheet, search=search, - has_linkedin=has_linkedin, has_resume=has_resume, + has_linkedin=has_linkedin, has_resume=has_resume, city=city, ): statement = statement.where(clause) row = (await session.execute(statement)).one() @@ -422,6 +493,7 @@ class FormData(SQLModel, table=True): "processed": int(row.processed or 0), "rejected": int(row.rejected or 0), "duplicates": int(row.duplicates or 0), + "on_hold": int(row.on_hold or 0), } @classmethod @@ -431,6 +503,15 @@ class FormData(SQLModel, table=True): ) return list(result.scalars().all()) + @classmethod + async def distinct_cities(cls, session: AsyncSession): + """Non-blank city values on this table. Distinct only within form_data.""" + col = func.coalesce(cls.city, cls.residing_city) + result = await session.execute( + select(col).where(col.is_not(None), col != "").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( @@ -548,6 +629,7 @@ class FormData(SQLModel, table=True): "profile_link": cls._cell(data, "LinkedIn Profile Link"), "residing_country": cls._cell(data, "Residing Country"), "residing_city": cls._cell(data, "Residing City"), + "city": cls._cell(data, "Residing City"), "ho_availability": cls._cell(data, "Are you willing to relocate?"), "degree": cls._cell(data, "Educational Degree"), "university": cls._cell(data, "University"), diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index 3764182..e232cd9 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -252,6 +252,10 @@ class SheetImport(SheetRead): ] mapped=await FormData.stamp_suggested_job_posts(session,mapped) result=await FormData.replace_sheet(session,tab,mapped) + from inbox.views import Reapplied + await Reapplied(session=session).sync_for_emails( + [r.get("candidate_email") for r in mapped] + ) return serialize_import({ "tab":tab, "rows_read":len(indexed), @@ -432,17 +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, ): 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, + has_linkedin=has_linkedin,has_resume=has_resume,city=city, + 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, + has_linkedin=has_linkedin,has_resume=has_resume,city=city, + no_suggestions=no_suggestions, ) items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows]) from job.candidate.views import CandidateView @@ -473,6 +480,8 @@ class SheetFormData(Sheet): updated=await FormData.set_job_post(session,record_id,job_post_id) if not updated: raise HTTPException(status_code=404,detail="Form data not found") + from inbox.views import Reapplied + await Reapplied(session=session).sync_for_email(updated.candidate_email) if job_post_id is not None: await self._promote_to_application(updated) from g_sheet.scoring import enqueue_form_score @@ -567,6 +576,8 @@ class SheetFormData(Sheet): "full_text":"", "linkedin_url":linkedin_url, }) + from inbox.views import Reapplied + await Reapplied(session=session).sync_for_email(email) await FormData.link_manual_upload(session,form_row.id,row.id) try: await HistoryRecorder(session).record( @@ -589,10 +600,10 @@ 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): + async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None): return await FormData.count_processing( self._require_session(),sheet=sheet,search=search, - has_linkedin=has_linkedin,has_resume=has_resume, + has_linkedin=has_linkedin,has_resume=has_resume,city=city, ) async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None): diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 2052cf0..ed14f85 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -19,6 +19,20 @@ router = APIRouter() _optional_bearer=HTTPBearer(auto_error=False) +def _city_values(city: str | None): + if not city or not str(city).strip(): + return None + parts=[p.strip() for p in str(city).split(",") if p.strip()] + return parts or None + + +def _apps_payload(items,total,cities=None): + body={"data":items,"total":total,"status_code":200} + if cities is not None: + body["cities"]=cities + return JSONResponse(content=body) + + def _cron_inbox_sync_token_ok(provided: str) -> bool: expected=(os.getenv("CRON_INBOX_SYNC_TOKEN") or "").strip() token=(provided or "").strip() @@ -81,6 +95,7 @@ class ReadAllBody(BaseModel): 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 @@ -286,6 +301,7 @@ async def mark_all_inbox_read( application_status=payload.application_status, assigned=payload.assigned, is_duplicate=payload.is_duplicate, + no_suggestions=payload.no_suggestions, processing_state=payload.processing_state, ) return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) @@ -321,6 +337,8 @@ async def get_all_applications( no_suggestions: bool | None = Query(default=None), processing_state: str | None = Query(default=None), search: str | None = Query(None), + city: 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), skip: int = Query(0, ge=0), @@ -329,22 +347,24 @@ async def get_all_applications( ): try: service=Email(session=session) + city_values=_city_values(city) + cities=await service.list_cities() 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) - 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) - return JSONResponse(content={"data":items,"total":total,"status_code":200}) + 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) 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) - total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state) - return JSONResponse(content={"data":items,"total":total,"status_code":200}) + 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) if record_id: item=await service.get_application_by_id(record_id) - return JSONResponse(content={"data":item,"total":1,"status_code":200}) + return _apps_payload(item,1,cities) - items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state) - total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state) - return JSONResponse(content={"data":items,"total":total,"status_code":200}) + 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) except HTTPException: raise except Exception as e: diff --git a/backend/inbox/models.py b/backend/inbox/models.py index c27dba4..9ddf3e2 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -681,6 +681,7 @@ class Inbox_Messages(SQLModel, table=True): is_duplicate: bool = Field(default=False, sa_column_kwargs={"server_default": "false"}) source_channel_id: int | None = Field(default=None, foreign_key="source_channels.id") processing_state: str = Field(default="unread", sa_column_kwargs={"server_default": "unread"}) + city: str | None = Field(default=None) # Ingestion time — list screens order by this (newest first). server_default # backfills existing rows on the ALTER so NOT NULL is safe on a populated table. created_at: datetime = Field( @@ -724,6 +725,7 @@ class Inbox_Messages(SQLModel, table=True): current_employment=None, current_title=None, linkedin_url=None, + city=None, suggested_job_post_ids=None, summary="", reasoning="", @@ -737,6 +739,7 @@ class Inbox_Messages(SQLModel, table=True): if resume_text is not None: row.resume_text = resume_text url = (linkedin_url or "").strip() or None + user_id = None if url: row.linkedin_slug = slug_from_url(url) or NO_SLUG user_id = await cls.get_linked_user_id(session, row.id) @@ -748,6 +751,15 @@ class Inbox_Messages(SQLModel, table=True): # Agent ran and found no profile — mark scanned so talent backfill # does not regex-scan this CV again. row.linkedin_slug = NO_SLUG + city_value = (city or "").strip() or None + if city_value: + row.city = city_value + if user_id is None: + user_id = await cls.get_linked_user_id(session, row.id) + if user_id: + await Users.set_city_if_empty( + session, user_id=user_id, city=city_value, + ) if candidate_phone_number is not None: row.candidate_phone_number = candidate_phone_number if candidate_education is not None: @@ -924,6 +936,37 @@ class Inbox_Messages(SQLModel, table=True): await session.execute(select(Inbox.user_id).where(Inbox.message_id==mid)) ).scalar_one_or_none() + @classmethod + async def assigned_job_post_ids_by_emails(cls,session:AsyncSession,emails): + """(email, assigned_job_post_id) for senders or linked users. Unlinked skipped.""" + from users.models import Users + lowers=sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + if not lowers: + return [] + result=await session.execute( + select(cls.message_from,cls.assigned_job_post_id,Users.email) + .select_from(cls) + .outerjoin(Inbox,Inbox.message_id==cls.id) + .outerjoin(Users,Inbox.user_id==Users.id) + .where(cls.assigned_job_post_id.is_not(None)) + .where(or_( + func.lower(cls.message_from).in_(lowers), + func.lower(Users.email).in_(lowers), + )) + ) + rows=[] + for sender,job_id,user_email in result.all(): + if job_id is None: + continue + jid=str(job_id) + sender_key=(sender or "").strip().lower() + user_key=(user_email or "").strip().lower() + if sender_key in lowers: + rows.append((sender_key,jid)) + if user_key in lowers and user_key!=sender_key: + rows.append((user_key,jid)) + return rows + @classmethod async def set_file_paths(cls,session:AsyncSession,record_id,file_paths,file_names=None): row=await cls.get_inbox_message_by_id(session,record_id) @@ -963,6 +1006,22 @@ class Inbox_Messages(SQLModel, table=True): cls.message_body.ilike(pattern), ) + @classmethod + def _no_suggested_jobs(cls): + """True when suggested_job_post_ids is missing, not an array, or []. + + jsonb_array_length() raises on scalar JSONB (a lone uuid string, an + object, json null). CASE evaluates WHEN arms in order, so length is + only read after jsonb_typeof confirms an array. + """ + typeof = func.jsonb_typeof(cls.suggested_job_post_ids) + return case( + (cls.suggested_job_post_ids.is_(None), True), + (typeof != "array", True), + (func.jsonb_array_length(cls.suggested_job_post_ids) == 0, True), + else_=False, + ) + @classmethod def _apply_filters( cls, statement, search: str | None=None, isread: bool=True, @@ -971,6 +1030,8 @@ class Inbox_Messages(SQLModel, table=True): is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, + city=None, + inbox_filter: str | None=None, ): """The one WHERE chain shared by the list, the count and the bulk read UPDATE. @@ -994,14 +1055,20 @@ class Inbox_Messages(SQLModel, table=True): elif is_duplicate is False: statement = statement.where(cls.is_duplicate==False) # noqa: E712 if no_suggestions is True: - statement = statement.where(cls.assigned_job_post_id.is_(None)).where( - or_( - cls.suggested_job_post_ids.is_(None), - func.jsonb_array_length(cls.suggested_job_post_ids) == 0, - ) - ) + statement = statement.where(cls._no_suggested_jobs()) if processing_state: 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])) + if inbox_filter == "matched": + statement = statement.where(cls.match_status == "matched") + elif inbox_filter == "unassigned": + statement = statement.where(cls.assigned_job_post_id.is_(None)) + elif inbox_filter == "rejected": + statement = statement.where(cls.processing_state == "rejected") + elif inbox_filter == "duplicate": + statement = statement.where(cls.is_duplicate == True) # noqa: E712 # Inbox / Job Matching only list applications that arrived with a file. # Graph hasAttachments lands on this column; body-only mail stays out. statement = statement.where(cls.attachment == True) # noqa: E712 @@ -1009,13 +1076,13 @@ 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, 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, light: bool=False ): statement = cls._apply_filters( select(cls).order_by(cls.message_received_time.desc(),cls.id.desc()), search, isread, application_status, assigned, is_duplicate, - no_suggestions, processing_state, + no_suggestions, processing_state, city, ) if skip: statement = statement.offset(skip) @@ -1096,15 +1163,26 @@ 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): + 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): statement = cls._apply_filters( select(func.count()).select_from(cls), search, isread, application_status, assigned, is_duplicate, - no_suggestions, processing_state, + no_suggestions, processing_state, city, ) result = await session.execute(statement) return result.scalar_one() + @classmethod + async def distinct_cities(cls, session: AsyncSession): + """Non-blank city values on inbox applications. Distinct only within this table.""" + result = await session.execute( + select(cls.city) + .where(cls.city.is_not(None), cls.city != "") + .where(cls.attachment == True) # noqa: E712 + .distinct() + ) + return [text.strip() for text in result.scalars().all() if (text or "").strip()] + @classmethod async def apply_read_status(cls, session: AsyncSession, changes) -> int: """[{id, isRead, ...}] -> bulk UPDATE message_read. Returns rows touched. @@ -1205,6 +1283,7 @@ class Inbox_Messages(SQLModel, table=True): assigned: bool | None=None, is_duplicate: bool | None=None, processing_state: str | None=None, + no_suggestions: bool | None=None, ) -> int: """Mark every row matching a list filter. Returns rows actually CHANGED. @@ -1213,7 +1292,7 @@ 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,processing_state=processing_state) + statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate,no_suggestions,processing_state) statement=statement.where(cls.message_read!=bool(read)) result=await session.execute( statement.values(message_read=bool(read),read_overridden_at=_now()) @@ -1230,6 +1309,7 @@ class Inbox_Messages(SQLModel, table=True): func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"), func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"), func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712 + func.coalesce(func.sum(case((cls._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"), func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"), func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"), ).where(cls.attachment == True) # noqa: E712 @@ -1241,6 +1321,7 @@ class Inbox_Messages(SQLModel, table=True): "processed": int(row.processed or 0), "rejected": int(row.rejected or 0), "duplicates": int(row.duplicates or 0), + "on_hold": int(row.on_hold or 0), "assigned": int(row.assigned or 0), "unassigned": int(row.unassigned or 0), } diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index cde7ac2..1b46c42 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -141,6 +141,7 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light: "experience": message.experience or "", "current_employment": message.current_employment or "", "current_title": message.current_title or "", + "city": message.city or None, "recruiter": str(message.recruiter_id) if message.recruiter_id else None, "duplicate": message.is_duplicate, "processing_state": message.processing_state, diff --git a/backend/inbox/tasks.py b/backend/inbox/tasks.py index bb74a22..a0784d0 100644 --- a/backend/inbox/tasks.py +++ b/backend/inbox/tasks.py @@ -146,6 +146,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict: current_title=fields["current_title"] linkedin_url=fields["linkedin_url"] phone=fields["phone"] + city=fields.get("city") or None async with session_scope() as session: await Inbox_Messages.set_match_result( @@ -158,6 +159,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict: current_title=current_title, candidate_education=education, linkedin_url=linkedin_url, + city=city, suggested_job_post_ids=result.get("suggested_job_post_ids") or [], summary=result.get("summary") or "", reasoning=result.get("reasoning") or "", diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 2e58b43..906aa90 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -212,6 +212,7 @@ class Email: row,new_user_email=await Inbox_Messages.insert_email( session=self.session,email_data=data,file_path=None, ) + await Reapplied(session=self.session).sync_for_email(row.message_from) if pdfs: row=await attach_email_pdfs_to_s3( self.session,row,pdfs,created_new=(already is None), @@ -273,18 +274,57 @@ class Email: item["assigned_job_post"]=None return await cv.attach_application_history(item) - 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): + 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): 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,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,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,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,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,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,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) from job.candidate.views import CandidateView 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. + + 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. + """ + ids=[] + for item in items: + aid=item.get("assigned_job_post_id") + if aid: + ids.append(aid) + for sid in item.get("suggested_job_post_ids") or []: + if sid: + ids.append(sid) + 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) + if post.is_deleted or not post.is_active: + payload={**payload,"unavailable":True} + by_id[str(post.id)]=payload + for item in items: + aid=item.get("assigned_job_post_id") + item["assigned_job_post"]=by_id.get(str(aid)) if aid else None + suggested=[] + for sid in item.get("suggested_job_post_ids") or []: + if not sid: + continue + payload=by_id.get(str(sid)) + if payload is None: + suggested.append({"id":str(sid),"unavailable":True}) + else: + suggested.append(dict(payload)) + item["suggested_job_posts"]=suggested + return items + async def get_application_by_id(self,record_id): message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) if not message: @@ -438,13 +478,20 @@ 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): + 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): 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) + 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) 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) + 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) 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) + 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) + + async def list_cities(self): + """Distinct cities from inbox_messages and form_data, merged in Python.""" + 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) async def assign_job_post(self,record_id,job_post_id): message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) @@ -458,6 +505,7 @@ class Email: updated=await Inbox_Messages.set_assigned_job_post(self.session,record_id,job_post_id) if not updated: raise HTTPException(status_code=404,detail="Message not found") + await Reapplied(session=self.session).sync_for_email(updated.message_from) if job_post_id is not None: # Assignment pairs this CV with a JD we already have — queue the ATS # score in the background so the recruiter is not held on an OpenAI @@ -502,7 +550,7 @@ 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,processing_state=None): + assigned=None,is_duplicate=None,no_suggestions=None,processing_state=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 @@ -512,7 +560,7 @@ class Email: updated=await Inbox_Messages.set_read_scope( self.session,read,search=search,isread=isread, application_status=application_status,assigned=assigned,is_duplicate=is_duplicate, - processing_state=processing_state, + no_suggestions=no_suggestions,processing_state=processing_state, ) 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) @@ -618,6 +666,7 @@ class Email: message,new_user_email=await Inbox_Messages.insert_email( session=self.session,email_data=data,file_path=None, ) + await Reapplied(session=self.session).sync_for_email(message.message_from) if pdfs: message=await attach_email_pdfs_to_s3( self.session,message,pdfs,created_new=(already is None), @@ -708,3 +757,78 @@ class Email: except Exception as exc: logger.warning("notification insert skipped: %s",exc) return {"accepted":True,"to":to_email,"subject":subject} + + +class Reapplied: + def __init__(self,session:AsyncSession): + self.session=session + self.collected={} + self.seen={} + + def _norm_email(self,email): + return (email or "").strip().lower() + + def _as_job_id(self,value): + if value in (None,""): + return None + text=str(value).strip() + return text or None + + def _add(self,email,job_id): + uid=self._as_job_id(job_id) + if not uid or email not in self.seen or uid in self.seen[email]: + return + self.seen[email].add(uid) + self.collected[email].append(uid) + + def merge_cities(self,*groups): + """Case-insensitive unique cities, first spelling wins, sorted.""" + seen=set() + out=[] + for group in groups: + for raw in group or []: + text=(raw or "").strip() + if not text: + continue + key=text.lower() + if key in seen: + continue + seen.add(key) + out.append(text) + out.sort(key=str.lower) + return out + + async def sync_for_email(self,email): + stamped=await self.sync_for_emails([email]) + return stamped.get(self._norm_email(email),[]) + + async def sync_for_emails(self,emails): + """Collect linked job_post_ids for these emails and stamp reapplied on all 3 tables. + + Records with no job_post_id are ignored while collecting. Empty collections + leave reapplied untouched. + """ + from g_sheet.models import FormData + from job.candidate.models import Manual_UPLOAD_CANDIDATE + from users.models import Users + + lowers=sorted({self._norm_email(e) for e in (emails or []) if self._norm_email(e)}) + if not lowers: + return {} + self.collected={email:[] for email in lowers} + self.seen={email:set() for email in lowers} + + for email,job_id in await Manual_UPLOAD_CANDIDATE.job_post_ids_by_emails(self.session,lowers): + self._add(email,job_id) + for email,job_id in await FormData.job_post_ids_by_emails(self.session,lowers): + self._add(email,job_id) + for email,job_id in await Inbox_Messages.assigned_job_post_ids_by_emails(self.session,lowers): + self._add(email,job_id) + + stamped={email:ids for email,ids in self.collected.items() if ids} + if not stamped: + return {} + await Users.set_reapplied_by_emails(self.session,stamped) + await Manual_UPLOAD_CANDIDATE.set_reapplied_by_emails(self.session,stamped) + await FormData.set_reapplied_by_emails(self.session,stamped) + return stamped diff --git a/backend/job/app.py b/backend/job/app.py index caee209..a84023c 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -355,6 +355,8 @@ async def cv_bank_upload( bank_reason="speculative", retention_months=CV_BANK_RETENTION_MONTHS, ) + from inbox.views import Reapplied + await Reapplied(session=session).sync_for_email(detected or "") try: uploaded=S3().upload_for_record( content, diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index d4027aa..cacd2cb 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, List, Optional from fastapi import HTTPException -from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_ +from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -59,6 +59,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): # the numeric years the bank filters and sorts by, so they stay separate. skills: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"}) years_experience: int | None = Field(default=None) + city: str | None = Field(default=None) + reapplied: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"}) education: str = Field(default="", sa_column_kwargs={"server_default": ""}) # Why the CV is held (speculative / referral) and when retention expires. bank_reason: str = Field(default="", sa_column_kwargs={"server_default": ""}) @@ -321,6 +323,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): referral_by=(fields.get("referral_by") or "").strip(), file_name=(fields.get("file_name") or "").strip(), file_path=(fields.get("file_path") or "").strip(), + city=(fields.get("city") or "").strip() or None, ) session.add(row) await session.commit() @@ -433,6 +436,40 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): }) return rows + @classmethod + async def job_post_ids_by_emails(cls, session: AsyncSession, emails): + """(email, job_post_id) pairs that actually have a job. Unlinked rows skipped.""" + lowers=sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + if not lowers: + return [] + result=await session.execute( + select(cls.candidate_email,cls.job_post_id) + .where(func.lower(cls.candidate_email).in_(lowers)) + .where(cls.job_post_id.is_not(None)) + ) + rows=[] + for email,job_id in result.all(): + if job_id is None: + continue + rows.append(((email or "").strip().lower(),str(job_id))) + return rows + + @classmethod + async def set_reapplied_by_emails(cls, session: AsyncSession, mapping): + if not mapping: + return 0 + updated=0 + for email, ids in mapping.items(): + key=(email or "").strip().lower() + if not key: + continue + result=await session.execute( + update(cls).where(func.lower(cls.candidate_email)==key).values(reapplied=list(ids or [])) + ) + updated+=result.rowcount or 0 + await session.commit() + return updated + @classmethod async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None): """Newest applications with a user + job for Talent Pool (manual / form).""" @@ -603,6 +640,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): status="BANKED", file_name=(file_name or "").strip(), file_path="", + city=(extracted.get("city") or "").strip() or None, ) session.add(row) await session.flush() @@ -613,6 +651,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): file_path=None, data=pdf_bytes, )) + if user and row.city: + await Users.set_city_if_empty(session, user_id=user.id, city=row.city) await session.commit() await session.refresh(row) return row @@ -681,6 +721,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): if not (row.linkedin_url or "").strip() and profile.get("linkedin_url"): row.linkedin_url = profile["linkedin_url"] row.linkedin_slug = slug_from_url(profile["linkedin_url"]) or NO_SLUG + if not (row.city or "").strip() and profile.get("city"): + row.city = (profile.get("city") or "").strip() or None row.updated_at = _now() session.add(row) await session.commit() diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 5af8097..d0cdc73 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -274,7 +274,7 @@ async def extract_bank_profile_from_cv(resume_text) -> dict: """ blank={ "linkedin_url":None,"current_company":"","current_position":"", - "education":"","candidate_phone":"","skills":[],"years_experience":None, + "education":"","candidate_phone":"","city":None,"skills":[],"years_experience":None, } text=(resume_text or "").strip() if not text: @@ -303,6 +303,7 @@ async def extract_bank_profile_from_cv(resume_text) -> dict: "current_position":unless_sentinel("current_title",CURRENT_TITLE), "education":unless_sentinel("education",EDUCATION), "candidate_phone":(fields.get("phone") or "").strip(), + "city":(fields.get("city") or "").strip() or None, "skills":fields.get("skills") if isinstance(fields.get("skills"),list) else [], "years_experience":years if isinstance(years,int) else None, } @@ -479,6 +480,8 @@ class FileRead: row,new_user_email=await Inbox_Messages.insert_email( self.session,email_data,file_path=None, ) + from inbox.views import Reapplied + await Reapplied(session=self.session).sync_for_email(email) try: row=await attach_email_pdfs_to_s3(self.session,row,pdfs,created_new=True) except Exception as e: @@ -994,6 +997,8 @@ class CandidateView: "created_by":current_user, } row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session=self.session,fields=data) + from inbox.views import Reapplied + await Reapplied(session=self.session).sync_for_email(email) if file_bytes is not None: try: diff --git a/backend/migrations/manual/031_candidate_city_reapplied.sql b/backend/migrations/manual/031_candidate_city_reapplied.sql new file mode 100644 index 0000000..d579bd1 --- /dev/null +++ b/backend/migrations/manual/031_candidate_city_reapplied.sql @@ -0,0 +1,40 @@ +-- 031_candidate_city_reapplied.sql +-- City of residence (employment-agent extraction) on the four candidate +-- surfaces, plus a denormalised reapplied JSONB list of prior job_post_ids +-- on users / manual_upload_candidate / form_data. +-- +-- form_data already has residing_city from the sheet; city is a separate +-- column so inbox / users / bank share one name. Existing sheet values are +-- copied across. Applied at startup by alembic_setup.run_manual_sql(). + +ALTER TABLE app.users + ADD COLUMN IF NOT EXISTS city TEXT, + ADD COLUMN IF NOT EXISTS reapplied JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE app.manual_upload_candidate + ADD COLUMN IF NOT EXISTS city TEXT, + ADD COLUMN IF NOT EXISTS reapplied JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE app.form_data + ADD COLUMN IF NOT EXISTS city TEXT, + ADD COLUMN IF NOT EXISTS reapplied JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE app.inbox_messages + ADD COLUMN IF NOT EXISTS city TEXT; + +UPDATE app.form_data +SET city = residing_city +WHERE city IS NULL + AND COALESCE(TRIM(residing_city), '') <> ''; + +CREATE INDEX IF NOT EXISTS ix_users_city + ON app.users (city); + +CREATE INDEX IF NOT EXISTS ix_manual_upload_candidate_city + ON app.manual_upload_candidate (city); + +CREATE INDEX IF NOT EXISTS ix_form_data_city + ON app.form_data (city); + +CREATE INDEX IF NOT EXISTS ix_inbox_messages_city + ON app.inbox_messages (city); diff --git a/backend/users/models.py b/backend/users/models.py index 323f394..be94d9c 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -3,7 +3,8 @@ import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING,List,Optional -from sqlalchemy import DateTime, func, or_ +from sqlalchemy import DateTime, func, or_, update +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select @@ -60,6 +61,10 @@ class Users(SQLModel, table=True): # Public profile URL extracted from a CV at ingest. NULL until a CV # mentions LinkedIn; never overwrite a stored value with empty. linkedin_url: str | None = Field(default=None) + city: str | None = Field(default=None) + # Prior job_post_ids for this email across candidate tables. [] until a + # later application finds an already-linked job. + reapplied: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"}) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) is_active: bool = Field(default=False) @@ -251,6 +256,30 @@ class Users(SQLModel, table=True): session.add(user) return True + @classmethod + async def set_city_if_empty(cls, session: AsyncSession, *, user_id=None, email=None, city=None) -> bool: + """Write city only when the user has none yet. Caller commits.""" + value = (city or "").strip() or None + if not value: + return False + statement = select(cls) + if user_id is not None: + uid = cls._as_uuid(user_id) + if uid is None: + return False + statement = statement.where(cls.id == uid) + elif email: + statement = statement.where(func.lower(cls.email) == str(email).strip().lower()) + else: + return False + user = (await session.execute(statement)).scalars().first() + if user is None or (user.city or "").strip(): + return False + user.city = value + user.updated_at = _now() + session.add(user) + return True + @classmethod async def get_pending_approvals(cls, session: AsyncSession): """Email-confirmed staff accounts waiting on an admin to set is_approved.""" @@ -308,4 +337,22 @@ class Users(SQLModel, table=True): return user + @classmethod + async def set_reapplied_by_emails(cls, session: AsyncSession, mapping): + """Write reapplied job_post_id lists keyed by lowercased email.""" + if not mapping: + return 0 + updated=0 + for email, ids in mapping.items(): + key=(email or "").strip().lower() + if not key: + continue + result=await session.execute( + update(cls).where(func.lower(cls.email)==key).values(reapplied=list(ids or [])) + ) + updated+=result.rowcount or 0 + await session.commit() + return updated + + import job.candidate.models as _candidate_models # noqa: E402, F401 diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index 6cc2dbc..cc7dd52 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -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 } = {}) { +export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, 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 @@ -28,10 +28,11 @@ export function listApplications({ search, top, skip, recordId, isread, applicat // Same for `application_status`: omit for every tab (server defaults to // CLOSED = no filter), send PROCESS / REJECTED for those tabs only. // Same for `is_duplicate`: omit unless the Duplicates tab. - // `no_suggestions`: Job Matching "No suggestions" tab — unassigned + empty - // suggested_job_post_ids. Omit unless that tab. + // `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. params: { search, top, @@ -43,6 +44,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat is_duplicate: isDuplicate, no_suggestions: noSuggestions, processing_state: processingState, + city, + city_list: cityList, }, }) } @@ -125,7 +128,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, processingState } = {}) { +export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState } = {}) { return request('/inbox/read-all', { method: 'PATCH', body: { @@ -135,6 +138,7 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned, application_status: applicationStatus, assigned, is_duplicate: isDuplicate, + no_suggestions: noSuggestions, processing_state: processingState, }, }) diff --git a/frontend/src/api/sheet.js b/frontend/src/api/sheet.js index 29a436f..73362ea 100644 --- a/frontend/src/api/sheet.js +++ b/frontend/src/api/sheet.js @@ -24,12 +24,12 @@ export function listFormDataSheets() { */ export function listFormData({ sheet, search, offset = 0, limit, processing_state, is_duplicate, - hasLinkedin, hasResume, + hasLinkedin, hasResume, city, no_suggestions, } = {}) { return request('/sheet/form-data/fetch', { params: { sheet, search, offset, limit, processing_state, is_duplicate, - has_linkedin: hasLinkedin, has_resume: hasResume, + has_linkedin: hasLinkedin, has_resume: hasResume, city, 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 } = {}) { +export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city } = {}) { return request('/sheet/form-data/counts', { - params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume }, + params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city }, }) } diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 5a9ccbd..0b8e962 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -37,6 +37,7 @@ export const qk = { formCounts: (p = {}) => ['mailbox', 'form-counts', p], applicationTotal: () => ['mailbox', 'application-total'], formTotal: (p = {}) => ['mailbox', 'form-total', p], + cities: () => ['mailbox', 'cities'], }, assessments: { all: () => ['assessments'], diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index c2c3d2d..5e0e788 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -37,9 +37,9 @@ import { inboxSources, sourceMeta, } from '../data/seed' -const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates'] +const TABS = ['All Applications', 'Unread', 'Processed', 'On-Hold', 'Rejected', 'Duplicates'] /** Sheet Forms have no mailbox read state — no Unread tab on that channel. */ -const FORM_TABS = ['All Applications', 'Processed', 'Rejected', 'Duplicates'] +const FORM_TABS = ['All Applications', 'Processed', 'On-Hold', 'Rejected', 'Duplicates'] /** Inbox GET `top` / sheet GET `limit` both cap at 500. */ const PAGE_SIZE_MAX = 500 @@ -71,8 +71,8 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS } /** Typing must not put a query key (and a skeleton) on screen per keystroke. */ const SEARCH_DEBOUNCE_MS = 300 -/** Sheet Forms link filters. '' = any, 'yes' / 'no' are both real filters. */ -const EMPTY_FORM_FILTERS = { hasLinkedin: '', hasResume: '' } +/** Inbox list filters. '' = any. LinkedIn / Resume only apply on Sheet Forms. */ +const EMPTY_INBOX_FILTERS = { location: '', hasLinkedin: '', hasResume: '' } /** "Updated 4 min ago" under the search box — the honest label for a cached list. */ function agoLabel(ts) { @@ -117,18 +117,21 @@ const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet' /** * Server-side filters for each tab. Email Processed / Rejected follow - * processing_state (the same writes as Import / Shortlist / Reject). Sheet - * Forms use form_data.processing_state. + * processing_state (the same writes as Import / Shortlist / Reject). On-Hold + * is applications with no suggested job post. Sheet Forms use the same + * processing_state / no_suggestions columns on form_data. */ const TAB_FILTERS = { Unread: { isread: false }, Processed: { processingState: 'processed' }, + 'On-Hold': { noSuggestions: true }, Rejected: { processingState: 'rejected' }, Duplicates: { isDuplicate: true }, } const FORM_TAB_FILTERS = { Processed: { processing_state: 'processed' }, + 'On-Hold': { no_suggestions: true }, Rejected: { processing_state: 'rejected' }, Duplicates: { is_duplicate: true }, } @@ -251,6 +254,36 @@ function asAtsScore(value) { return Number.isFinite(n) ? n : 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 + if (fromPost) return fromPost + const id = row?.assignedId + if (!id) return '' + const fromCards = (row.jobPosts || []).find((p) => String(p.id) === String(id)) + return fromCards?.title || '' +} + +/** Suggestion titles, assigned job omitted so the two export columns do not repeat. */ +function suggestedJobTitles(row) { + const assignedId = row?.assignedId ? String(row.assignedId) : '' + const posts = Array.isArray(row?.suggestedPosts) && row.suggestedPosts.length + ? row.suggestedPosts + : (row?.jobPosts || []) + const titles = [] + const seen = new Set() + for (const post of posts) { + if (assignedId && String(post?.id) === assignedId) continue + const title = String(post?.title || '').trim() + if (!title) continue + const key = title.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + titles.push(title) + } + return titles.join('; ') +} + /** * GET /sheet/form-data/fetch row → the same list/detail shape the email channel * uses for name / avatar / position / source / time, plus form-only profile fields. @@ -291,7 +324,8 @@ function mapFormRow(row) { university: row.university || '', universityOther: row.university_other || '', graduationYear: row.entry_year || '', - residingCity: row.residing_city || '', + residingCity: row.city || row.residing_city || '', + city: row.city || row.residing_city || '', residingCountry: row.residing_country || '', maritalStatus: row.marital_status || '', hoAvailability: row.ho_availability || '', @@ -503,11 +537,15 @@ async function fetchApplications(params) { phone: row.phone, experience: row.experience, recruiter: row.recruiter, + city: row.city || '', + residingCity: row.city || '', duplicate: Boolean(row.duplicate), suggestedIds: Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids.map(String) : [], + 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, isReapplicant: Boolean(row.is_reapplicant), previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], } @@ -844,22 +882,23 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, } /** - * Sheet Forms link filters, stacked for the queue column. + * Inbox list filters, stacked for the queue column. * * NOT the shared `.filter-panel` used by Candidates and CvBank: that lays out in * a four-column grid whose breakpoints watch the VIEWPORT, while this column is * `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. * - * Collapsed by default because two permanent selects eat scarce vertical space - * above the queue — but the toggle carries the active count, because a collapsed - * panel silently hiding a filter is how a recruiter concludes the list is broken. + * 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. * * (`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.) */ -function FormLinkFilters({ filters, active, open, onToggle, onChange, onClear }) { +function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle, onChange, onClear }) { return (
)} @@ -985,14 +1044,14 @@ export default function Inbox() { // '' means no filter; 'yes' / 'no' are both real filters. Finding the rows // MISSING a link is half the reason this exists, so 'no' cannot collapse into // "unset" the way a checkbox would force it to. - const [formFilters, setFormFilters] = useState(EMPTY_FORM_FILTERS) - const [showFormFilters, setShowFormFilters] = useState(false) - const activeFormFilters = Object.values(formFilters).filter(Boolean).length - const setFormFilter = useCallback((key, value) => { - setFormFilters((f) => ({ ...f, [key]: value })) + const [inboxFilters, setInboxFilters] = useState(EMPTY_INBOX_FILTERS) + const [showInboxFilters, setShowInboxFilters] = useState(false) + const setInboxFilter = useCallback((key, value) => { + setInboxFilters((f) => ({ ...f, [key]: value })) setSkip(0) // page 4 of the old result set is meaningless in the new one setSelectedId(null) }, []) + const city = inboxFilters.location const deepOpen = searchParams.get('open') const deepKind = searchParams.get('kind') @@ -1016,6 +1075,10 @@ export default function Inbox() { const isAllChannel = channel === 'all' const channelTabs = isForms || isAllChannel ? FORM_TABS : TABS const pageLimit = pageSize === 'all' ? undefined : pageSize + const activeInboxFilters = [ + inboxFilters.location, + ...(isForms ? [inboxFilters.hasLinkedin, inboxFilters.hasResume] : []), + ].filter(Boolean).length const tabFilter = TAB_FILTERS[tab] ?? {} const formTabFilter = FORM_TAB_FILTERS[tab] ?? {} @@ -1025,7 +1088,8 @@ export default function Inbox() { top: pageLimit, skip: pageLimit == null ? 0 : skip, ...(search ? { search } : {}), - }), [tabFilter, skip, pageLimit, search]) + ...(city ? { city } : {}), + }), [tabFilter, skip, pageLimit, search, city]) /** * Sheet Forms only. On the All channel these rows are merged with email ones, @@ -1035,9 +1099,9 @@ export default function Inbox() { * the inbox endpoint the same filters. Separate change. */ const linkFilters = useMemo(() => (isForms ? { - ...(formFilters.hasLinkedin ? { hasLinkedin: formFilters.hasLinkedin === 'yes' } : {}), - ...(formFilters.hasResume ? { hasResume: formFilters.hasResume === 'yes' } : {}), - } : {}), [isForms, formFilters]) + ...(inboxFilters.hasLinkedin ? { hasLinkedin: inboxFilters.hasLinkedin === 'yes' } : {}), + ...(inboxFilters.hasResume ? { hasResume: inboxFilters.hasResume === 'yes' } : {}), + } : {}), [isForms, inboxFilters]) const formParams = useMemo(() => ({ // All channel spans every sheet tab, not just the selected one. @@ -1046,8 +1110,19 @@ export default function Inbox() { limit: pageLimit, ...formTabFilter, ...(search ? { search } : {}), + ...(city ? { city } : {}), ...linkFilters, - }), [formSheet, skip, pageLimit, search, formTabFilter, isAllChannel, linkFilters]) + }), [formSheet, skip, pageLimit, search, city, 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 : [] + }, + staleTime: 5 * 60 * 1000, + }) + const cities = citiesQuery.data ?? [] const applicationsQuery = useQuery({ queryKey: qk.mailbox.applications(listParams), @@ -1088,8 +1163,9 @@ export default function Inbox() { const formCountsParams = useMemo(() => ({ sheet: formCountsSheet, ...(search ? { search } : {}), + ...(city ? { city } : {}), ...linkFilters, - }), [formCountsSheet, search, linkFilters]) + }), [formCountsSheet, search, city, linkFilters]) const formCountsQuery = useQuery({ queryKey: qk.mailbox.formCounts(formCountsParams), queryFn: async () => { @@ -1183,6 +1259,7 @@ export default function Inbox() { 'All Applications': pick('all'), Unread: pick('unread'), Processed: pick('processed'), + 'On-Hold': pick('on_hold'), Rejected: pick('rejected'), Duplicates: pick('duplicates'), } @@ -1200,7 +1277,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 + const total = (search || city) ? searchTotal : (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0))) // 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing @@ -1297,7 +1374,7 @@ 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 - setFormFilters(EMPTY_FORM_FILTERS) + setInboxFilters((f) => ({ ...EMPTY_INBOX_FILTERS, location: f.location })) // Unread is email-only; leave it behind when opening Sheet Forms or All. if (next !== 'email' && tab === 'Unread') setTab('All Applications') selection.clear() @@ -1427,14 +1504,15 @@ export default function Inbox() { { header: 'Notice period', key: 'notice', width: 13 }, { header: 'ATS score', key: 'ats', width: 10 }, { header: 'Assigned job', key: 'job', width: 24 }, + { header: 'Suggested jobs', key: 'suggested', width: 40 }, ], rows: rows.map((r) => ({ name: r.name, email: r.email, phone: r.phone, position: r.position, channel: r.kind === 'form' ? 'Sheet Form' : 'Email', source: r.source, received: r.received ? fmtDate(r.received) : '', - status: r.processing, city: r.residingCity, notice: r.noticePeriod, - ats: r.atsScore, job: r.assignedPost?.title, + status: r.processing, city: r.city || r.residingCity, notice: r.noticePeriod, + ats: r.atsScore, job: assignedJobTitle(r), suggested: suggestedJobTitles(r), })), }) toast(`Exported ${rows.length} application${rows.length === 1 ? '' : 's'}`, 'success') @@ -1609,16 +1687,16 @@ export default function Inbox() { placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'} /> - {isForms && ( - setShowFormFilters((s) => !s)} - onChange={setFormFilter} - onClear={() => { setFormFilters(EMPTY_FORM_FILTERS); setSkip(0); setSelectedId(null) }} - /> - )} + setShowInboxFilters((s) => !s)} + onChange={setInboxFilter} + onClear={() => { setInboxFilters(EMPTY_INBOX_FILTERS); setSkip(0); setSelectedId(null) }} + />
@@ -1730,8 +1808,8 @@ export default function Inbox() { {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( {formatRole(i.applicationStatus)} )} - {i.kind === 'form' && i.residingCity && ( - {i.residingCity} + {(i.city || i.residingCity) && ( + {i.city || i.residingCity} )}
From 41f46c2c391d2352d009e5205c831109f6769459 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 7 Sep 2026 17:36:36 +0500 Subject: [PATCH 2/3] dashboard sheet added --- frontend/src/lib/exportXlsx.js | 83 +++++--- frontend/src/screens/Dashboard.jsx | 305 ++++++++++++++++++++++++++++- 2 files changed, 360 insertions(+), 28 deletions(-) diff --git a/frontend/src/lib/exportXlsx.js b/frontend/src/lib/exportXlsx.js index d5fb919..6198d7a 100644 --- a/frontend/src/lib/exportXlsx.js +++ b/frontend/src/lib/exportXlsx.js @@ -24,30 +24,15 @@ const BRAND = { const thin = { style: 'thin', color: { argb: BRAND.border } } -/** - * Build and download a styled .xlsx. - * - * @param {object} opts - * @param {string} opts.filename without extension - * @param {string} opts.title big brand-band line, e.g. "Recruitment Inbox" - * @param {string} opts.subtitle meta line, e.g. "All Applications · 512 rows · 02/09/2026" - * @param {Array<{header: string, key: string, width?: number}>} opts.columns - * @param {Array} opts.rows keyed by columns[].key; null/undefined print blank - */ -export async function exportStyledXlsx({ filename, title, subtitle, columns, rows }) { - const ExcelJS = (await import('exceljs')).default - const wb = new ExcelJS.Workbook() - wb.creator = 'TalentFlow ATS' - wb.created = new Date() - - const ws = wb.addWorksheet(title.slice(0, 31) || 'Export', { - views: [{ state: 'frozen', ySplit: 3 }], - }) +function sanitizeSheetName(name) { + const cleaned = String(name || 'Export').replace(/[:\\/?*[\]]/g, ' ').trim() + return cleaned.slice(0, 31) || 'Export' +} +function paintSheet(ws, { title, subtitle, columns, rows }) { ws.columns = columns.map((c) => ({ key: c.key, width: c.width ?? 18 })) - const span = columns.length + const span = Math.max(1, columns.length) - // Row 1 — brand title band const titleRow = ws.addRow([title]) ws.mergeCells(1, 1, 1, span) titleRow.height = 30 @@ -56,7 +41,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.green } } titleCell.alignment = { vertical: 'middle', indent: 1 } - // Row 2 — meta line const metaRow = ws.addRow([subtitle]) ws.mergeCells(2, 1, 2, span) metaRow.height = 20 @@ -65,7 +49,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row metaCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.mint } } metaCell.alignment = { vertical: 'middle', indent: 1 } - // Row 3 — column headers const headRow = ws.addRow(columns.map((c) => c.header)) headRow.height = 22 headRow.eachCell((cell) => { @@ -75,7 +58,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row cell.border = { bottom: { style: 'medium', color: { argb: BRAND.lime } } } }) - // Data — zebra rows with hairline borders for (const [i, r] of rows.entries()) { const row = ws.addRow(columns.map((c) => r[c.key] ?? '')) row.eachCell({ includeEmpty: true }, (cell) => { @@ -87,7 +69,9 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row } ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: span } } +} +async function downloadWorkbook(wb, filename) { const buf = await wb.xlsx.writeBuffer() const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) const a = document.createElement('a') @@ -98,3 +82,54 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row a.remove() URL.revokeObjectURL(a.href) } + +/** + * Build and download a styled multi-sheet .xlsx. + * + * @param {object} opts + * @param {string} opts.filename without extension + * @param {Array<{name?: string, title: string, subtitle: string, columns: Array<{header: string, key: string, width?: number}>, rows: Array}>} opts.sheets + */ +export async function exportStyledWorkbook({ filename, sheets }) { + const ExcelJS = (await import('exceljs')).default + const wb = new ExcelJS.Workbook() + wb.creator = 'TalentFlow ATS' + wb.created = new Date() + + const used = new Set() + for (const sheet of sheets) { + if (!sheet?.columns?.length) continue + let name = sanitizeSheetName(sheet.name || sheet.title) + if (used.has(name)) { + const base = name.slice(0, 28) + let n = 2 + while (used.has(`${base} ${n}`)) n += 1 + name = `${base} ${n}` + } + used.add(name) + const ws = wb.addWorksheet(name, { + views: [{ state: 'frozen', ySplit: 3 }], + }) + paintSheet(ws, sheet) + } + + if (!used.size) throw new Error('Nothing to export') + await downloadWorkbook(wb, filename) +} + +/** + * Build and download a styled .xlsx. + * + * @param {object} opts + * @param {string} opts.filename without extension + * @param {string} opts.title big brand-band line, e.g. "Recruitment Inbox" + * @param {string} opts.subtitle meta line, e.g. "All Applications · 512 rows · 02/09/2026" + * @param {Array<{header: string, key: string, width?: number}>} opts.columns + * @param {Array} opts.rows keyed by columns[].key; null/undefined print blank + */ +export async function exportStyledXlsx({ filename, title, subtitle, columns, rows }) { + return exportStyledWorkbook({ + filename, + sheets: [{ name: title, title, subtitle, columns, rows }], + }) +} diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index aa90eaa..8b0c1ff 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -31,11 +31,14 @@ import Charts from '../lib/charts' import ChartCard, { widgetError } from '../ui/ChartCard' import PageHeader from '../ui/PageHeader' import { Badge, EmptyState, Icon, KpiTile } from '../ui/primitives' +import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { isHiringManager } from '../auth/permissions' import { qk } from '../lib/queryKeys' +import { exportStyledWorkbook } from '../lib/exportXlsx' import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges' -import { fmtWeekdayDate, fmtShort } from '../lib/format' +import { fmtWeekdayDate, fmtShort, fmtDate } from '../lib/format' +import { friendlyAuthError } from '../lib/errors' import { money } from '../data/seed' import * as activityApi from '../api/activity' import * as analyticsApi from '../api/analytics' @@ -102,6 +105,261 @@ function fmtWhen(iso) { return fmtShort(iso) || '—' } +function starvingCountOf(jobs) { + return asList(jobs).filter((r) => r.requisition_status === 'open' && !r.count).length +} + +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 +} + +function kpiChange(kpis, key, priorKey, kind) { + if (!kpis) return '' + const text = kind === 'days' + ? dayDelta(kpis[key], kpis[priorKey]) + : pctDelta(kpis[key], kpis[priorKey]) + return text || '' +} + +async function settle(promise) { + try { + return await promise + } catch { + return null + } +} + +const KPI_EXPORT_ROWS = [ + { label: 'Open Jobs', key: 'open_jobs', prior: 'open_jobs_prior', delta: 'pct' }, + { label: 'Applications', key: 'total_candidates', prior: 'total_candidates_prior', delta: 'pct' }, + { label: 'Hires', key: 'hires', prior: 'hires_prior', delta: 'pct' }, + { label: 'Offers Sent', key: 'offers_sent', prior: 'offers_sent_prior', delta: 'pct' }, + { label: 'Offers Accepted', key: 'offers_accepted', prior: 'offers_accepted_prior', delta: 'pct' }, + { label: 'Interviews Today', key: 'interviews_today' }, + { label: 'Interviews Upcoming', key: 'interviews_upcoming' }, + { label: 'Time to Hire (days)', key: 'time_to_hire', prior: 'time_to_hire_prior', delta: 'days', round: true }, + { label: 'Cost per Hire', key: 'cost_per_hire', prior: 'cost_per_hire_prior', delta: 'pct', round: true }, +] + +async function fetchRangeSnapshot(rangeKey, department) { + const span = rangeWindow(rangeKey) + const filters = { ...span, department: department || undefined } + const [kpisRes, jobsRes, funnelRes] = await Promise.all([ + analyticsApi.kpis(filters), + analyticsApi.applicationsPerJob({ top: JOBS_FETCHED, ...filters }), + analyticsApi.funnel(filters), + ]) + return { + key: rangeKey, + label: rangeLabel(rangeKey), + fromDate: span.fromDate, + toDate: span.toDate, + kpis: asObject(kpisRes?.data), + jobs: asList(jobsRes?.data), + funnel: asList(funnelRes?.data), + } +} + +function buildDashboardSheets({ + department, + snapshots, + offers, + inboxCounts, + activity, + trend, + exportedAt, +}) { + const deptLabel = department || 'All Departments' + const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10) + const windowNote = `Week / Month / Quarter / Year · ${deptLabel} · exported ${stamp}` + const periodCols = RANGES.flatMap((r) => ([ + { header: r.label, key: r.key, width: 12 }, + { header: `${r.label} vs prior`, key: `${r.key}_delta`, width: 16 }, + ])) + + const overview = { + name: 'Overview', + title: 'Dashboard Export', + subtitle: windowNote, + columns: [ + { header: 'Field', key: 'field', width: 28 }, + { header: 'Value', key: 'value', width: 48 }, + ], + rows: [ + { field: 'Exported at', value: stamp }, + { field: 'Department', value: deptLabel }, + ...snapshots.map((s) => ({ + field: `${s.label} window`, + value: `${fmtDate(s.fromDate) || s.fromDate} – ${fmtDate(s.toDate) || s.toDate}`, + })), + ], + } + + const kpis = { + name: 'KPIs', + title: 'Dashboard KPIs', + subtitle: `Headline tiles across every window · ${windowNote}`, + columns: [ + { header: 'Metric', key: 'metric', width: 28 }, + ...periodCols, + ], + rows: [ + ...KPI_EXPORT_ROWS.map((spec) => { + const row = { metric: spec.label } + for (const s of snapshots) { + row[s.key] = kpiValue(s.kpis, spec.key, { round: spec.round }) + row[`${s.key}_delta`] = spec.prior + ? kpiChange(s.kpis, spec.key, spec.prior, spec.delta) + : '' + } + return row + }), + (() => { + const row = { metric: 'Open jobs with no applications' } + for (const s of snapshots) { + row[s.key] = starvingCountOf(s.jobs) + row[`${s.key}_delta`] = '' + } + return row + })(), + ], + } + + const jobRows = snapshots.flatMap((s) => ( + asList(s.jobs).map((j) => ({ + period: s.label, + title: j.title || '', + department: j.department || '', + status: jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status || '', + vacancies: j.vacancies ?? '', + applications: j.count ?? 0, + })) + )) + + const jobs = { + name: 'Applications per Job', + title: 'Applications per Job', + subtitle: `Same source as the dashboard hero · ${windowNote}`, + columns: [ + { header: 'Period', key: 'period', width: 12 }, + { header: 'Job Title', 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: 'applications', width: 14 }, + ], + rows: jobRows, + } + + const pipelineRows = snapshots.flatMap((s) => { + const rows = analyticsApi.toBoardStageRows(s.funnel, { includeRejected: true }) + const total = rows.reduce((sum, r) => sum + r.count, 0) + return rows.map((r) => ({ + period: s.label, + stage: r.stage, + count: r.count, + share: total ? `${Math.round((r.count / total) * 100)}%` : '0%', + })) + }) + + const pipeline = { + name: 'Pipeline', + title: 'Candidate Pipeline', + subtitle: `Active by stage · ${windowNote}`, + columns: [ + { header: 'Period', key: 'period', width: 12 }, + { header: 'Stage', key: 'stage', width: 16 }, + { header: 'Count', key: 'count', width: 12 }, + { header: 'Share', key: 'share', width: 12 }, + ], + rows: pipelineRows, + } + + const offerCounts = Object.fromEntries(offersApi.OFFER_STATUSES.map((s) => [s, 0])) + for (const o of asList(offers)) { + if (o.status in offerCounts) offerCounts[o.status] += 1 + } + const offerBook = { + 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: offers == null + ? [{ status: 'Offers not visible', count: '' }] + : offersApi.OFFER_STATUSES.map((s) => ({ + status: offersApi.OFFER_STATUS_LABEL[s], + count: offerCounts[s], + })), + } + + const counts = inboxCounts ?? {} + const attention = { + name: 'Needs Attention', + title: 'Needs Attention', + subtitle: `Work queued right now · exported ${stamp}`, + columns: [ + { header: 'Item', key: 'item', width: 36 }, + { header: 'Count', key: 'count', width: 12 }, + { header: 'Scope', key: 'scope', width: 22 }, + ], + rows: [ + { item: 'Unread applications', count: counts.unread ?? '', scope: 'Now' }, + { item: 'Not assigned to a job', count: counts.unassigned ?? '', scope: 'Now' }, + { item: 'Flagged duplicates', count: counts.duplicates ?? '', scope: 'Now' }, + ...snapshots.map((s) => ({ + item: 'Open jobs with no applications', + count: starvingCountOf(s.jobs), + scope: s.label, + })), + ], + } + + const activitySheet = { + name: 'Recent Activity', + title: 'Recent Activity', + subtitle: `Latest across the org · exported ${stamp}`, + columns: [ + { header: 'When', key: 'when', width: 18 }, + { header: 'Type', key: 'type', width: 22 }, + { header: 'Actor', key: 'actor', width: 22 }, + { header: 'Status', key: 'status', width: 16 }, + { header: 'Description', key: 'description', width: 48 }, + ], + rows: asList(activity).map((a) => ({ + when: fmtWhen(a.activity_date), + type: a.activity_type || 'Activity', + actor: a.actor_name || '', + status: a.activity_status || '', + description: a.description || '', + })), + } + + const labels = asList(trend?.labels) + const trendSheet = { + name: 'Hiring Trend', + title: 'Hiring Trend', + subtitle: `Last ${TREND_MONTHS} months · sparkline source · exported ${stamp}`, + columns: [ + { header: 'Month', key: 'month', width: 16 }, + { header: 'Applications', key: 'applications', width: 14 }, + { header: 'Hires', key: 'hires', width: 12 }, + ], + rows: labels.map((month, i) => ({ + month, + applications: asList(trend?.applications)[i] ?? 0, + hires: asList(trend?.hires)[i] ?? 0, + })), + } + + return [overview, kpis, jobs, pipeline, offerBook, attention, activitySheet, trendSheet] +} + function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) { if (query.isPending) { return ( @@ -136,12 +394,14 @@ export default function Dashboard() { function DashboardHome() { const navigate = useNavigate() + const { toast } = useToast() const { user } = useAuth() const firstName = (user?.name || 'there').split(' ')[0] const todayLabel = formatDashDate() const [rangeKey, setRangeKey] = useState('month') const [department, setDepartment] = useState('') + const [exporting, setExporting] = useState(false) // rangeWindow returns fresh ISO strings on every call — recompute only when // the key changes, or every render would churn the query keys below. const span = useMemo(() => rangeWindow(rangeKey), [rangeKey]) @@ -353,6 +613,38 @@ function DashboardHome() { }, ] + async function exportDashboard() { + if (exporting) return + setExporting(true) + try { + const [snapshots, offersRes, inboxCounts, activityRes, trendRes] = await Promise.all([ + Promise.all(RANGES.map((r) => fetchRangeSnapshot(r.key, department))), + settle(offersApi.list({ top: 500 })), + settle(inboxApi.fetchCounts()), + settle(activityApi.feed({ top: 8 })), + settle(analyticsApi.hiringTrend({ months: TREND_MONTHS })), + ]) + 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: buildDashboardSheets({ + department, + snapshots, + offers: offersRes == null ? null : asList(offersRes?.data), + inboxCounts, + activity: asList(activityRes?.data), + trend: asObject(trendRes?.data) || { labels: [], applications: [], hires: [] }, + exportedAt: new Date(), + }), + }) + toast('Dashboard exported to Excel', 'success') + } catch (err) { + toast(friendlyAuthError(err, 'Could not export the dashboard'), 'error') + } finally { + setExporting(false) + } + } + return (
All Departments {departments.map((d) => )} - - Export - + Create Job From ded1b9053de92b0f7254eadf075b443f99e75c96 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 7 Sep 2026 17:53:49 +0500 Subject: [PATCH 3/3] dashboiard sheet export --- backend/g_sheet/models.py | 6 +- backend/inbox/models.py | 3 +- backend/inbox/serializers.py | 2 + frontend/src/screens/Dashboard.jsx | 282 ++++------------------------- frontend/src/screens/Inbox.jsx | 29 ++- 5 files changed, 66 insertions(+), 256 deletions(-) diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index b3d4451..01a407a 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -346,9 +346,10 @@ 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, offset=0, limit=None, + has_resume=None, city=None, no_suggestions=None, inbox_filter=None, + offset=0, limit=None, ): - statement = select(cls).order_by(cls.sheet, cls.row_number) + statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc()) for clause in cls._filters( sheet=sheet, search=search, processing_state=processing_state, is_duplicate=is_duplicate, @@ -360,7 +361,6 @@ class FormData(SQLModel, table=True): statement = statement.offset(offset) if limit is not None: statement = statement.limit(limit) - statement = statement.order_by(cls.row_number) result = await session.execute(statement) return result.scalars().all() diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 9ddf3e2..42d3d4f 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -1078,9 +1078,8 @@ class Inbox_Messages(SQLModel, table=True): async def get_inbox_messages( cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, light: bool=False ): - statement = cls._apply_filters( - select(cls).order_by(cls.message_received_time.desc(),cls.id.desc()), + select(cls).order_by(cls.created_at.desc(), cls.id.desc()), search, isread, application_status, assigned, is_duplicate, no_suggestions, processing_state, city, ) diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 1b46c42..e22b5c7 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -61,6 +61,7 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict: "body": message.message_body, "when": message.message_received_time, "received": message.message_received_time, + "created_at": message.created_at.isoformat() if message.created_at else None, "unread": not message.message_read, "attachment": message.attachment, "attachment_name": attachment_name, @@ -120,6 +121,7 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light: "position": message.message_subject, "source": message.message_to, "received": message.message_received_time, + "created_at": message.created_at.isoformat() if message.created_at else None, "unread": not message.message_read, "processing": processing, "application_status": message.application_status, diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index 8b0c1ff..8b74e19 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -105,10 +105,22 @@ function fmtWhen(iso) { return fmtShort(iso) || '—' } -function starvingCountOf(jobs) { - return asList(jobs).filter((r) => r.requisition_status === 'open' && !r.count).length +const PERIOD_SHEET_NAMES = { + week: 'Weekly', + month: 'Monthly', + quarter: 'Quarterly', + year: 'Yearly', } +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]) @@ -116,248 +128,42 @@ function kpiValue(kpis, key, { round = false } = {}) { return round ? Math.round(n) : n } -function kpiChange(kpis, key, priorKey, kind) { - if (!kpis) return '' - const text = kind === 'days' - ? dayDelta(kpis[key], kpis[priorKey]) - : pctDelta(kpis[key], kpis[priorKey]) - return text || '' -} - -async function settle(promise) { - try { - return await promise - } catch { - return null - } -} - -const KPI_EXPORT_ROWS = [ - { label: 'Open Jobs', key: 'open_jobs', prior: 'open_jobs_prior', delta: 'pct' }, - { label: 'Applications', key: 'total_candidates', prior: 'total_candidates_prior', delta: 'pct' }, - { label: 'Hires', key: 'hires', prior: 'hires_prior', delta: 'pct' }, - { label: 'Offers Sent', key: 'offers_sent', prior: 'offers_sent_prior', delta: 'pct' }, - { label: 'Offers Accepted', key: 'offers_accepted', prior: 'offers_accepted_prior', delta: 'pct' }, - { label: 'Interviews Today', key: 'interviews_today' }, - { label: 'Interviews Upcoming', key: 'interviews_upcoming' }, - { label: 'Time to Hire (days)', key: 'time_to_hire', prior: 'time_to_hire_prior', delta: 'days', round: true }, - { label: 'Cost per Hire', key: 'cost_per_hire', prior: 'cost_per_hire_prior', delta: 'pct', round: true }, -] - async function fetchRangeSnapshot(rangeKey, department) { const span = rangeWindow(rangeKey) - const filters = { ...span, department: department || undefined } - const [kpisRes, jobsRes, funnelRes] = await Promise.all([ - analyticsApi.kpis(filters), - analyticsApi.applicationsPerJob({ top: JOBS_FETCHED, ...filters }), - analyticsApi.funnel(filters), - ]) + const kpisRes = await analyticsApi.kpis({ + ...span, + department: department || undefined, + }) return { key: rangeKey, - label: rangeLabel(rangeKey), fromDate: span.fromDate, toDate: span.toDate, kpis: asObject(kpisRes?.data), - jobs: asList(jobsRes?.data), - funnel: asList(funnelRes?.data), } } -function buildDashboardSheets({ - department, - snapshots, - offers, - inboxCounts, - activity, - trend, - exportedAt, -}) { +function buildPeriodSheets({ department, snapshots, exportedAt }) { const deptLabel = department || 'All Departments' const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10) - const windowNote = `Week / Month / Quarter / Year · ${deptLabel} · exported ${stamp}` - const periodCols = RANGES.flatMap((r) => ([ - { header: r.label, key: r.key, width: 12 }, - { header: `${r.label} vs prior`, key: `${r.key}_delta`, width: 16 }, - ])) - - const overview = { - name: 'Overview', - title: 'Dashboard Export', - subtitle: windowNote, - columns: [ - { header: 'Field', key: 'field', width: 28 }, - { header: 'Value', key: 'value', width: 48 }, - ], - rows: [ - { field: 'Exported at', value: stamp }, - { field: 'Department', value: deptLabel }, - ...snapshots.map((s) => ({ - field: `${s.label} window`, - value: `${fmtDate(s.fromDate) || s.fromDate} – ${fmtDate(s.toDate) || s.toDate}`, + const columns = [ + { header: 'Metric', key: 'metric', width: 22 }, + { header: 'Value', key: 'value', width: 16 }, + ] + 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 }), })), - ], - } - - const kpis = { - name: 'KPIs', - title: 'Dashboard KPIs', - subtitle: `Headline tiles across every window · ${windowNote}`, - columns: [ - { header: 'Metric', key: 'metric', width: 28 }, - ...periodCols, - ], - rows: [ - ...KPI_EXPORT_ROWS.map((spec) => { - const row = { metric: spec.label } - for (const s of snapshots) { - row[s.key] = kpiValue(s.kpis, spec.key, { round: spec.round }) - row[`${s.key}_delta`] = spec.prior - ? kpiChange(s.kpis, spec.key, spec.prior, spec.delta) - : '' - } - return row - }), - (() => { - const row = { metric: 'Open jobs with no applications' } - for (const s of snapshots) { - row[s.key] = starvingCountOf(s.jobs) - row[`${s.key}_delta`] = '' - } - return row - })(), - ], - } - - const jobRows = snapshots.flatMap((s) => ( - asList(s.jobs).map((j) => ({ - period: s.label, - title: j.title || '', - department: j.department || '', - status: jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status || '', - vacancies: j.vacancies ?? '', - applications: j.count ?? 0, - })) - )) - - const jobs = { - name: 'Applications per Job', - title: 'Applications per Job', - subtitle: `Same source as the dashboard hero · ${windowNote}`, - columns: [ - { header: 'Period', key: 'period', width: 12 }, - { header: 'Job Title', 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: 'applications', width: 14 }, - ], - rows: jobRows, - } - - const pipelineRows = snapshots.flatMap((s) => { - const rows = analyticsApi.toBoardStageRows(s.funnel, { includeRejected: true }) - const total = rows.reduce((sum, r) => sum + r.count, 0) - return rows.map((r) => ({ - period: s.label, - stage: r.stage, - count: r.count, - share: total ? `${Math.round((r.count / total) * 100)}%` : '0%', - })) + } }) - - const pipeline = { - name: 'Pipeline', - title: 'Candidate Pipeline', - subtitle: `Active by stage · ${windowNote}`, - columns: [ - { header: 'Period', key: 'period', width: 12 }, - { header: 'Stage', key: 'stage', width: 16 }, - { header: 'Count', key: 'count', width: 12 }, - { header: 'Share', key: 'share', width: 12 }, - ], - rows: pipelineRows, - } - - const offerCounts = Object.fromEntries(offersApi.OFFER_STATUSES.map((s) => [s, 0])) - for (const o of asList(offers)) { - if (o.status in offerCounts) offerCounts[o.status] += 1 - } - const offerBook = { - 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: offers == null - ? [{ status: 'Offers not visible', count: '' }] - : offersApi.OFFER_STATUSES.map((s) => ({ - status: offersApi.OFFER_STATUS_LABEL[s], - count: offerCounts[s], - })), - } - - const counts = inboxCounts ?? {} - const attention = { - name: 'Needs Attention', - title: 'Needs Attention', - subtitle: `Work queued right now · exported ${stamp}`, - columns: [ - { header: 'Item', key: 'item', width: 36 }, - { header: 'Count', key: 'count', width: 12 }, - { header: 'Scope', key: 'scope', width: 22 }, - ], - rows: [ - { item: 'Unread applications', count: counts.unread ?? '', scope: 'Now' }, - { item: 'Not assigned to a job', count: counts.unassigned ?? '', scope: 'Now' }, - { item: 'Flagged duplicates', count: counts.duplicates ?? '', scope: 'Now' }, - ...snapshots.map((s) => ({ - item: 'Open jobs with no applications', - count: starvingCountOf(s.jobs), - scope: s.label, - })), - ], - } - - const activitySheet = { - name: 'Recent Activity', - title: 'Recent Activity', - subtitle: `Latest across the org · exported ${stamp}`, - columns: [ - { header: 'When', key: 'when', width: 18 }, - { header: 'Type', key: 'type', width: 22 }, - { header: 'Actor', key: 'actor', width: 22 }, - { header: 'Status', key: 'status', width: 16 }, - { header: 'Description', key: 'description', width: 48 }, - ], - rows: asList(activity).map((a) => ({ - when: fmtWhen(a.activity_date), - type: a.activity_type || 'Activity', - actor: a.actor_name || '', - status: a.activity_status || '', - description: a.description || '', - })), - } - - const labels = asList(trend?.labels) - const trendSheet = { - name: 'Hiring Trend', - title: 'Hiring Trend', - subtitle: `Last ${TREND_MONTHS} months · sparkline source · exported ${stamp}`, - columns: [ - { header: 'Month', key: 'month', width: 16 }, - { header: 'Applications', key: 'applications', width: 14 }, - { header: 'Hires', key: 'hires', width: 12 }, - ], - rows: labels.map((month, i) => ({ - month, - applications: asList(trend?.applications)[i] ?? 0, - hires: asList(trend?.hires)[i] ?? 0, - })), - } - - return [overview, kpis, jobs, pipeline, offerBook, attention, activitySheet, trendSheet] } function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) { @@ -617,23 +423,15 @@ function DashboardHome() { if (exporting) return setExporting(true) try { - const [snapshots, offersRes, inboxCounts, activityRes, trendRes] = await Promise.all([ - Promise.all(RANGES.map((r) => fetchRangeSnapshot(r.key, department))), - settle(offersApi.list({ top: 500 })), - settle(inboxApi.fetchCounts()), - settle(activityApi.feed({ top: 8 })), - settle(analyticsApi.hiringTrend({ months: TREND_MONTHS })), - ]) + const snapshots = await Promise.all( + RANGES.map((r) => fetchRangeSnapshot(r.key, department)), + ) 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: buildDashboardSheets({ + sheets: buildPeriodSheets({ department, snapshots, - offers: offersRes == null ? null : asList(offersRes?.data), - inboxCounts, - activity: asList(activityRes?.data), - trend: asObject(trendRes?.data) || { labels: [], applications: [], hires: [] }, exportedAt: new Date(), }), }) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 5e0e788..a6c91c6 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -315,6 +315,7 @@ function mapFormRow(row) { position: row.position_applied_for || '—', ...FORM_LIST_SOURCE, received: formReceivedAt(row.entry_date, row.entry_time, rawFormTimestamp(row)), + createdAt: parseGraphDate(row.created_at), screenedBy: row.screened_by || '', hrComments: row.hr_comments || '', gender: row.gender || '', @@ -464,6 +465,7 @@ async function fetchMessageDetail(recordId) { position: row.subject || '(no subject)', ...sourceFrom(row.message_to), received: parseGraphDate(row.when) ?? parseGraphDate(row.message_sent_time), + createdAt: parseGraphDate(row.created_at), unread: Boolean(row.unread), processing: row.unread ? 'Unread' : 'Read', resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending', @@ -521,6 +523,7 @@ async function fetchApplications(params) { position: row.position || '(no subject)', ...sourceFrom(row.source), received: parseGraphDate(row.received), + createdAt: parseGraphDate(row.created_at), unread: Boolean(row.unread), processing: row.processing || 'Unread', processingState: row.processing_state || null, @@ -898,7 +901,12 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, * 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 + function InboxFilters({ filters, cities, 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()) return (