diff --git a/backend/analytics/serializers.py b/backend/analytics/serializers.py index 50ebb1f..8be2007 100644 --- a/backend/analytics/serializers.py +++ b/backend/analytics/serializers.py @@ -20,11 +20,12 @@ def serialize_source_count(source,count,source_id=None,spend=0.0) -> dict: } -def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire) -> dict: +def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire,completed=0) -> dict: return { "id": str(user_id) if user_id else None, "name": name, "hires": int(hires or 0), + "completed": int(completed or 0), "open_reqs": int(open_reqs or 0), "avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None, } diff --git a/backend/analytics/views.py b/backend/analytics/views.py index 6a4b994..17a5bc2 100644 --- a/backend/analytics/views.py +++ b/backend/analytics/views.py @@ -1,7 +1,5 @@ -import uuid from datetime import datetime,timedelta,timezone -from sqlalchemy import and_,func,or_,select from sqlalchemy.ext.asyncio import AsyncSession from analytics.serializers import ( @@ -14,22 +12,14 @@ from inbox.models import Inbox,Inbox_Messages,SourceChannels from job.assignment.models import JobAssignments from job.candidate.models import ApplicationStageTransitions,Interviews from job.cost.models import HiringCosts +from job.job_post.enums import RequisitionStatus from job.job_post.models import JobPosts from offer.models import Offers from org_settings.models import OrgSettings -from role.models import EnumRoles,Roles +from role.models import EnumRoles from users.models import Users -def _as_uuid(value): - if value in (None,""): - return None - try: - return uuid.UUID(str(value)) - except (TypeError,ValueError): - return None - - def _month_start(dt: datetime) -> datetime: return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) @@ -75,220 +65,29 @@ def _month_key(dt): return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) -def _days_expr(end_col,start_col): - return func.extract("epoch",end_col-start_col)/86400.0 - - class Analytics: def __init__(self,session:AsyncSession): self.session=session - async def _count_jobs(self,status,from_date=None,to_date=None,department=None,recruiter_id=None,*,closed_in_window=False): - statement=select(func.count()).select_from(JobPosts).where(JobPosts.is_deleted==False) # noqa: E712 - if status: - statement=statement.where(JobPosts.requisition_status==status) - if department: - statement=statement.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - statement=statement.where(JobPosts.current_recruiter_id==rid) - if closed_in_window: - if from_date is not None: - statement=statement.where(JobPosts.closed_at>=from_date) - if to_date is not None: - statement=statement.where(JobPosts.closed_at=as_of), - JobPosts.requisition_status=="open", - ) - if department: - statement=statement.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - statement=statement.where(JobPosts.current_recruiter_id==rid) - result=await self.session.execute(statement) - return int(result.scalar_one() or 0) - - async def _count_candidates(self,from_date=None,to_date=None,department=None,recruiter_id=None): - statement=( - select(func.count()) - .select_from(Inbox) - .join(Users,Inbox.user_id==Users.id) - .join(Roles,Users.role_id==Roles.id) - .where(Roles.role_name==EnumRoles.CANDIDATE.value) - ) - if from_date is not None: - statement=statement.where(Inbox.created_at>=from_date) - if to_date is not None: - statement=statement.where(Inbox.created_at=from_date) - if to_date is not None: - statement=statement.where(stamp=from_date) - if to_date is not None: - statement=statement.where(hired.valid_from=from_date) - if to_date is not None: - msg=msg.where(Inbox.created_at=from_date) - if to_date is not None: - statement=statement.where(hire.c.valid_from=from_date) - if to_date is not None: - statement=statement.where(JobPosts.closed_at=from_date) - if to_date is not None: - statement=statement.where(HiringCosts.incurred_at=today_start, - Interviews.interview_date=now, + closed_jobs_prior=await JobPosts.count_requisitions( + self.session,status="closed",department=department,recruiter_id=recruiter_id, + from_date=prior_from,to_date=prior_to,closed_in_window=True, ) - interviews_upcoming=int((await self.session.execute(upcoming_q)).scalar_one() or 0) - next_q=select(func.min(func.coalesce(Interviews.interview_time,Interviews.interview_date))).where( - Interviews.interview_status.ilike("scheduled"), - Interviews.interview_date>=now, + total_candidates=await Inbox.count_in_window( + self.session,window_from,window_to,department,recruiter_id, + ) + total_candidates_prior=await Inbox.count_in_window( + self.session,prior_from,prior_to,department,recruiter_id, + ) + + interviews_today=await Interviews.count_between( + self.session,today_start,tomorrow,recruiter_id=recruiter_id, + ) + interviews_upcoming=await Interviews.count_upcoming( + self.session,now,recruiter_id=recruiter_id, + ) + next_at=await Interviews.next_scheduled_at( + self.session,now,recruiter_id=recruiter_id, ) - next_at=(await self.session.execute(next_q)).scalar_one() next_interview_at=next_at.isoformat() if next_at else None - offers_accepted=await self._count_offers(["accepted"],window_from,window_to,department,recruiter_id) - offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id) - offers_sent=await self._count_offers( - ["sent","negotiating","accepted","declined","expired"], - window_from,window_to,department,recruiter_id,exclude_draft=True, + offers_accepted=await Offers.count_in_window( + self.session,["accepted"],window_from,window_to,department,recruiter_id, ) - offers_sent_prior=await self._count_offers( - ["sent","negotiating","accepted","declined","expired"], - prior_from,prior_to,department,recruiter_id,exclude_draft=True, + offers_accepted_prior=await Offers.count_in_window( + self.session,["accepted"],prior_from,prior_to,department,recruiter_id, + ) + offers_sent=await Offers.count_in_window( + self.session,None,window_from,window_to,department,recruiter_id,exclude_draft=True, + ) + offers_sent_prior=await Offers.count_in_window( + self.session,None,prior_from,prior_to,department,recruiter_id,exclude_draft=True, ) hires=await self._count_hires(window_from,window_to,department,recruiter_id) hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id) - time_to_hire=await self._avg_time_to_hire(window_from,window_to,department,recruiter_id) - time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id) - time_to_fill=await self._avg_time_to_fill(window_from,window_to,department,recruiter_id) - time_to_fill_prior=await self._avg_time_to_fill(prior_from,prior_to,department,recruiter_id) + time_to_hire=await ApplicationStageTransitions.avg_time_to_hire( + self.session,window_from,window_to,department,recruiter_id, + ) + time_to_hire_prior=await ApplicationStageTransitions.avg_time_to_hire( + self.session,prior_from,prior_to,department,recruiter_id, + ) + time_to_fill=await JobPosts.avg_time_to_fill( + self.session,window_from,window_to,department,recruiter_id, + ) + time_to_fill_prior=await JobPosts.avg_time_to_fill( + self.session,prior_from,prior_to,department,recruiter_id, + ) cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id) - cost_per_hire_prior=await self._cost_per_hire(hires_prior,prior_from,prior_to,department,recruiter_id) + cost_per_hire_prior=await self._cost_per_hire( + hires_prior,prior_from,prior_to,department,recruiter_id, + ) # REQ-ANL-08: the time-to-hire baseline is an org setting with provenance # ({"days": N, "source": "..."}), never a constant — OPEN-12 flags the BRD's @@ -397,28 +210,10 @@ class Analytics: } async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None): - statement=select( - Inbox_Messages.application_status, - func.count().label("count"), - ).select_from(Inbox_Messages) - if department or recruiter_id: - statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) - if department: - statement=statement.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - statement=statement.where( - or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) - ) - if from_date is not None or to_date is not None: - statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) - if from_date is not None: - statement=statement.where(Inbox.created_at>=from_date) - if to_date is not None: - statement=statement.where(Inbox.created_at=start) - .group_by(month_bucket) - .order_by(month_bucket) - ) - if department or recruiter_id: - apps_q=( - apps_q - .outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) - .outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) - ) - if department: - apps_q=apps_q.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - apps_q=apps_q.where( - or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) - ) - apps_rows=await self.session.execute(apps_q) apps_map={} - for month,count in apps_rows.all(): - apps_map[_month_key(month)]=int(count or 0) + for month,count in await Inbox.counts_by_month( + self.session,start,department=department,recruiter_id=recruiter_id, + ): + apps_map[_month_key(month)]=count - hire_bucket=func.date_trunc("month",ApplicationStageTransitions.valid_from) - hires_q=( - select(hire_bucket.label("month"),func.count().label("count")) - .select_from(ApplicationStageTransitions) - .where( - ApplicationStageTransitions.to_stage==Candidate_application_Status.HIRED.value, - ApplicationStageTransitions.valid_from>=start, - ) - .group_by(hire_bucket) - .order_by(hire_bucket) - ) - if department or recruiter_id: - hires_q=( - hires_q - .outerjoin(Inbox,ApplicationStageTransitions.inbox_id==Inbox.id) - .outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) - .outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) - ) - if department: - hires_q=hires_q.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - hires_q=hires_q.where( - or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) - ) - hire_rows=await self.session.execute(hires_q) hire_map={} - for month,count in hire_rows.all(): - hire_map[_month_key(month)]=int(count or 0) + for month,count in await ApplicationStageTransitions.counts_hires_by_month( + self.session,start,department=department,recruiter_id=recruiter_id, + ): + hire_map[_month_key(month)]=count labels=[] applications=[] @@ -500,57 +250,18 @@ class Analytics: return {"labels": labels,"applications": applications,"hires": hires} async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None): - statement=( - select( - SourceChannels.id.label("source_id"), - func.coalesce(SourceChannels.label,"Unknown").label("source"), - func.count().label("count"), - ) - .select_from(Inbox_Messages) - .outerjoin(SourceChannels,Inbox_Messages.source_channel_id==SourceChannels.id) + rows=await Inbox_Messages.counts_by_source( + self.session,from_date=from_date,to_date=to_date, + department=department,recruiter_id=recruiter_id, ) - if department or recruiter_id: - statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) - if department: - statement=statement.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - statement=statement.where( - or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) - ) - if from_date is not None or to_date is not None: - statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) - if from_date is not None: - statement=statement.where(Inbox.created_at>=from_date) - if to_date is not None: - statement=statement.where(Inbox.created_at=from_date) - if to_date is not None: - spend_q=spend_q.where(HiringCosts.incurred_at=from_date) - if to_date is not None: - hires_q=hires_q.where(Inbox.created_at= from_date) + if to_date is not None: + statement = statement.where(cls.created_at < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def counts_by_month( + cls, session: AsyncSession, start, department=None, recruiter_id=None, + ): + month_bucket = func.date_trunc("month", cls.created_at) + statement = ( + select(month_bucket.label("month"), func.count().label("count")) + .select_from(cls) + .where(cls.created_at >= start) + .group_by(month_bucket) + .order_by(month_bucket) + ) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return [(month, int(count or 0)) for month, count in result.all()] + @classmethod async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None): if record_id is None: @@ -979,6 +1037,117 @@ class Inbox_Messages(SQLModel, table=True): await session.refresh(row) return row + @classmethod + async def count_hires_by_recruiter( + cls, session: AsyncSession, recruiter_id, *, from_date=None, to_date=None, department=None, + ): + """Messages currently HIRED and assigned to this recruiter_id.""" + try: + uid = uuid.UUID(str(recruiter_id)) + except (TypeError, ValueError): + return 0 + from job.job_post.models import JobPosts + + statement = select(func.count()).select_from(cls).where( + cls.recruiter_id == uid, + cls.application_status == Candidate_application_Status.HIRED, + ) + if department: + statement = statement.outerjoin(JobPosts, cls.assigned_job_post_id == JobPosts.id).where( + JobPosts.department == department + ) + if from_date is not None or to_date is not None: + statement = statement.join(Inbox, Inbox.message_id == cls.id) + if from_date is not None: + statement = statement.where(Inbox.created_at >= from_date) + if to_date is not None: + statement = statement.where(Inbox.created_at < to_date) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + def scoped_to_job(cls, statement, department=None, recruiter_id=None): + """Optional department / recruiter via assigned job post.""" + if not department and not recruiter_id: + return statement + from job.job_post.models import JobPosts + statement = statement.outerjoin(JobPosts, cls.assigned_job_post_id == JobPosts.id) + if department: + statement = statement.where(JobPosts.department == department) + try: + rid = uuid.UUID(str(recruiter_id)) if recruiter_id not in (None, "") else None + except (TypeError, ValueError): + rid = None + if rid is not None: + statement = statement.where( + or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid) + ) + return statement + + @classmethod + def _window_by_inbox(cls, statement, from_date=None, to_date=None): + if from_date is None and to_date is None: + return statement + statement = statement.join(Inbox, Inbox.message_id == cls.id) + if from_date is not None: + statement = statement.where(Inbox.created_at >= from_date) + if to_date is not None: + statement = statement.where(Inbox.created_at < to_date) + return statement + + @classmethod + async def count_hired( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + """Messages currently HIRED, windowed on inbox.created_at.""" + statement = ( + select(func.count()) + .select_from(cls) + .join(Inbox, Inbox.message_id == cls.id) + .where(cls.application_status == Candidate_application_Status.HIRED) + ) + if from_date is not None: + statement = statement.where(Inbox.created_at >= from_date) + if to_date is not None: + statement = statement.where(Inbox.created_at < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def counts_by_application_status( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + statement = select(cls.application_status, func.count().label("count")).select_from(cls) + statement = cls.scoped_to_job(statement, department, recruiter_id) + statement = cls._window_by_inbox(statement, from_date, to_date) + statement = statement.group_by(cls.application_status) + result = await session.execute(statement) + counts = {} + for status, n in result.all(): + key = str(status.value if hasattr(status, "value") else status) + counts[key] = int(n or 0) + return counts + + @classmethod + async def counts_by_source( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + statement = ( + select( + SourceChannels.id.label("source_id"), + func.coalesce(SourceChannels.label, "Unknown").label("source"), + func.count().label("count"), + ) + .select_from(cls) + .outerjoin(SourceChannels, cls.source_channel_id == SourceChannels.id) + ) + statement = cls.scoped_to_job(statement, department, recruiter_id) + statement = cls._window_by_inbox(statement, from_date, to_date) + statement = statement.group_by(SourceChannels.id, SourceChannels.label).order_by(func.count().desc()) + result = await session.execute(statement) + return [(source_id, source, int(count or 0)) for source_id, source, count in result.all()] + class Inbox_Message_Triage(SQLModel, table=True): """One intake verdict per upstream message id — the gate before inbox_messages. @@ -1168,6 +1337,14 @@ class SourceChannels(SQLModel, table=True): ) return list(result.scalars().all()) + @classmethod + async def labels_by_ids(cls, session: AsyncSession, ids): + keys = [cid for cid in (ids or []) if cid is not None] + if not keys: + return [] + result = await session.execute(select(cls.id, cls.label).where(cls.id.in_(keys))) + return [(cid, label) for cid, label in result.all()] + class AtsResults(SQLModel, table=True): __tablename__ = "ats_results" diff --git a/backend/job/app.py b/backend/job/app.py index 73751ed..14928a7 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -752,6 +752,39 @@ async def fetch_job_departments( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/jobs/requisition-statuses/fetch") +async def fetch_requisition_statuses( + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + """Hiring-lifecycle tags for the Jobs status dropdown (open/on_hold/closed/completed).""" + try: + service=JobPost(session=session) + data=await service.fetch_requisition_statuses() + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/jobs/status-history/fetch") +async def fetch_job_status_history( + job_post_id:str=Query(...), + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + """Who changed requisition_status on one job, from what, to what, and when.""" + try: + service=JobPost(session=session) + data=await service.fetch_status_history(job_post_id) + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/jobs/fetch") async def fetch_jobs( search: str | None = Query(None), @@ -895,6 +928,7 @@ async def fetch_interview( from_date:datetime=Query(None), to_date:datetime=Query(None), status:str=Query(None), + recruiter_id:str=Query(None), top:int=Query(None), skip:int=Query(0,ge=0), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), @@ -902,14 +936,14 @@ async def fetch_interview( ): try: service=Interview(session=session) - if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or top is not None): + if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or recruiter_id or top is not None): data,total=await service.get_interviews_range( - from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip, ) return JSONResponse(content={"data":data,"total":total,"status_code":200}) data=await service.get_interview( interview_id=interview_id,inbox_id=inbox_id, - from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip, ) if isinstance(data,tuple): data,total=data diff --git a/backend/job/assignment/models.py b/backend/job/assignment/models.py index 9d19580..371a703 100644 --- a/backend/job/assignment/models.py +++ b/backend/job/assignment/models.py @@ -105,6 +105,7 @@ class JobAssignments(SQLModel, table=True): @classmethod async def count_open_reqs_by_users(cls, session: AsyncSession, user_ids): """Open requisitions per user: current assignments joined to open job_posts.""" + from job.job_post.enums import RequisitionStatus from job.job_post.models import JobPosts uids = [u for u in (user_ids or []) if u] @@ -117,7 +118,7 @@ class JobAssignments(SQLModel, table=True): .where( cls.user_id.in_(uids), cls.valid_to.is_(None), - JobPosts.requisition_status == "open", + JobPosts.requisition_status == RequisitionStatus.OPEN.value, JobPosts.is_deleted == False, # noqa: E712 ) .group_by(cls.user_id) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 120e07b..cf90ff1 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, List, Optional from fastapi import HTTPException -from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, func, or_ +from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -790,6 +790,11 @@ class Interviews(SQLModel, table=True): interview_type: str = Field(default="") interview_status: str = Field(default="") inbox_id: int | None = Field(default=None, foreign_key="inbox.id") + # Optional denorm so Recruiter Hub can join interviews → job_posts.current_recruiter_id + # without walking inbox. Filled on create from the application's assigned job; + # migration 011 added the columns. user_id is the candidate, not the recruiter. + user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") graph_event_id: str | None = Field(default=None) web_link: str | None = Field(default=None) inbox: Optional["Inbox"] = Relationship( @@ -830,6 +835,28 @@ class Interviews(SQLModel, table=True): ) return result.scalars().all() + @classmethod + def scoped_to_recruiter(cls, statement, recruiter_id): + """Restrict an Interviews select to the recruiter who owns the job. + + COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id) + → job_posts.current_recruiter_id. Interviews with no job drop out. + """ + from inbox.models import Inbox, Inbox_Messages + from job.job_post.models import JobPosts + + rid = cls._as_uuid(recruiter_id) + if rid is None: + return statement + job_id = func.coalesce(cls.job_post_id, Inbox_Messages.assigned_job_post_id) + return ( + statement + .outerjoin(Inbox, cls.inbox_id == Inbox.id) + .outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id) + .outerjoin(JobPosts, JobPosts.id == job_id) + .where(JobPosts.current_recruiter_id == rid) + ) + @classmethod async def get_interviews_in_range( cls, @@ -838,6 +865,7 @@ class Interviews(SQLModel, table=True): from_date=None, to_date=None, status: str | None = None, + recruiter_id=None, top: int | None = None, skip: int = 0, ): @@ -848,6 +876,8 @@ class Interviews(SQLModel, table=True): statement = statement.where(cls.interview_date < to_date) if status: statement = statement.where(cls.interview_status == status) + if recruiter_id: + statement = cls.scoped_to_recruiter(statement, recruiter_id) count_statement = select(func.count()).select_from(statement.subquery()) total = (await session.execute(count_statement)).scalar_one() statement = ( @@ -860,6 +890,41 @@ class Interviews(SQLModel, table=True): result = await session.execute(statement) return list(result.scalars().all()), total + @classmethod + async def count_between(cls, session: AsyncSession, start, end, recruiter_id=None): + statement = select(func.count()).select_from(cls).where( + cls.interview_date >= start, + cls.interview_date < end, + ) + if recruiter_id: + statement = cls.scoped_to_recruiter(statement, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def count_upcoming(cls, session: AsyncSession, as_of, recruiter_id=None): + statement = select(func.count()).select_from(cls).where( + cls.interview_status.ilike("scheduled"), + cls.interview_date >= as_of, + ) + if recruiter_id: + statement = cls.scoped_to_recruiter(statement, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def next_scheduled_at(cls, session: AsyncSession, as_of, recruiter_id=None): + statement = select( + func.min(func.coalesce(cls.interview_time, cls.interview_date)) + ).select_from(cls).where( + cls.interview_status.ilike("scheduled"), + cls.interview_date >= as_of, + ) + if recruiter_id: + statement = cls.scoped_to_recruiter(statement, recruiter_id) + result = await session.execute(statement) + return result.scalar_one() + @classmethod async def job_titles_by_inbox(cls, session: AsyncSession, inbox_ids) -> dict[int, str]: """Resolve {inbox_id: job_title} for a page of interview rows. @@ -1222,6 +1287,113 @@ class ApplicationStageTransitions(SQLModel, table=True): result = await session.execute(statement) return result.scalar_one() + @classmethod + async def avg_time_to_hire( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + """Mean days from first stage (from_stage IS NULL) to HIRED, optional filters.""" + from inbox.enums import Candidate_application_Status + from inbox.models import Inbox, Inbox_Messages + from job.job_post.models import JobPosts + + entry = cls.__table__.alias("entry") + hire = cls.__table__.alias("hire") + days = func.extract("epoch", hire.c.valid_from - entry.c.valid_from) / 86400.0 + statement = ( + select(func.avg(days)) + .select_from( + hire.join( + entry, + and_( + hire.c.inbox_id == entry.c.inbox_id, + entry.c.from_stage.is_(None), + ), + ) + ) + .where(hire.c.to_stage == Candidate_application_Status.HIRED.value) + ) + if from_date is not None: + statement = statement.where(hire.c.valid_from >= from_date) + if to_date is not None: + statement = statement.where(hire.c.valid_from < to_date) + if department or recruiter_id: + statement = ( + statement + .outerjoin(Inbox, hire.c.inbox_id == Inbox.id) + .outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id) + .outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id) + ) + if department: + statement = statement.where(JobPosts.department == department) + rid = cls._as_uuid(recruiter_id) + if rid is not None: + statement = statement.where( + or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid) + ) + result = await session.execute(statement) + value = result.scalar_one() + return float(value) if value is not None else None + + @classmethod + def scoped_to_job(cls, statement, department=None, recruiter_id=None): + if not department and not recruiter_id: + return statement + from inbox.models import Inbox, Inbox_Messages + from job.job_post.models import JobPosts + + statement = ( + statement + .outerjoin(Inbox, cls.inbox_id == Inbox.id) + .outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id) + .outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id) + ) + if department: + statement = statement.where(JobPosts.department == department) + rid = cls._as_uuid(recruiter_id) + if rid is not None: + statement = statement.where( + or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid) + ) + return statement + + @classmethod + async def count_hires( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + from inbox.enums import Candidate_application_Status + + statement = select(func.count()).select_from(cls).where( + cls.to_stage == Candidate_application_Status.HIRED.value + ) + if from_date is not None: + statement = statement.where(cls.valid_from >= from_date) + if to_date is not None: + statement = statement.where(cls.valid_from < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def counts_hires_by_month( + cls, session: AsyncSession, start, department=None, recruiter_id=None, + ): + from inbox.enums import Candidate_application_Status + + month_bucket = func.date_trunc("month", cls.valid_from) + statement = ( + select(month_bucket.label("month"), func.count().label("count")) + .select_from(cls) + .where( + cls.to_stage == Candidate_application_Status.HIRED.value, + cls.valid_from >= start, + ) + .group_by(month_bucket) + .order_by(month_bucket) + ) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return [(month, int(count or 0)) for month, count in result.all()] + class CandidateHistory(SQLModel, table=True): """Append-only audit log for one candidate (users.id), scoped to an application. diff --git a/backend/job/cost/models.py b/backend/job/cost/models.py index 507f849..00d8d41 100644 --- a/backend/job/cost/models.py +++ b/backend/job/cost/models.py @@ -83,13 +83,46 @@ class HiringCosts(SQLModel, table=True): return await cls.get_by_id(session, row.id) @classmethod - async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None): + def scoped_to_job(cls, statement, department=None, recruiter_id=None): + if not department and not recruiter_id: + return statement + from job.job_post.models import JobPosts + statement = statement.outerjoin(JobPosts, cls.job_post_id == JobPosts.id) + if department: + statement = statement.where(JobPosts.department == department) + rid = cls._as_uuid(recruiter_id) + if rid is not None: + statement = statement.where(JobPosts.current_recruiter_id == rid) + return statement + + @classmethod + async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None, + department=None, recruiter_id=None): statement = select(func.coalesce(func.sum(cls.amount), 0.0)) if from_date is not None: statement = statement.where(cls.incurred_at >= from_date) if to_date is not None: statement = statement.where(cls.incurred_at < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) result = await session.execute(statement) return float(result.scalar_one() or 0.0) + @classmethod + async def sum_by_source_channel( + cls, session: AsyncSession, *, from_date=None, to_date=None, + department=None, recruiter_id=None, + ): + statement = select( + cls.source_channel_id, + func.coalesce(func.sum(cls.amount), 0.0), + ).where(cls.source_channel_id.is_not(None)) + if from_date is not None: + statement = statement.where(cls.incurred_at >= from_date) + if to_date is not None: + statement = statement.where(cls.incurred_at < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) + statement = statement.group_by(cls.source_channel_id) + result = await session.execute(statement) + return {channel_id: float(total or 0.0) for channel_id, total in result.all()} + import users.models as _users_models # noqa: E402, F401 diff --git a/backend/job/interviews/views.py b/backend/job/interviews/views.py index bc6d3eb..eb69c73 100644 --- a/backend/job/interviews/views.py +++ b/backend/job/interviews/views.py @@ -24,7 +24,7 @@ class Interview: async def _serialize(self,row): return serialize_interview(row,job_title=await self._job_title_for(row)) - async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,top=None,skip=0): + async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,recruiter_id=None,top=None,skip=0): if interview_id: row=await Interviews.get_interview_by_id(self.session,interview_id) if not row: @@ -33,18 +33,19 @@ class Interview: if inbox_id is not None: rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id)) return [serialize_interview(r) for r in rows] - if from_date is not None or to_date is not None or status is not None or top is not None: + if from_date is not None or to_date is not None or status is not None or recruiter_id or top is not None: return await self.get_interviews_range( - from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip, ) raise HTTPException(status_code=400,detail="interview_id or inbox_id is required") - async def get_interviews_range(self,from_date=None,to_date=None,status=None,top=None,skip=0): + async def get_interviews_range(self,from_date=None,to_date=None,status=None,recruiter_id=None,top=None,skip=0): rows,total=await Interviews.get_interviews_in_range( self.session, from_date=from_date, to_date=to_date, status=status, + recruiter_id=recruiter_id, top=top, skip=skip, ) @@ -52,6 +53,7 @@ class Interview: return [serialize_interview(r,job_title=titles.get(r.inbox_id)) for r in rows],total async def create_interview(self,payload,current_user=None): + from inbox.models import Inbox fields={ "interview_date":payload.get("interview_date"), "interview_time":payload.get("interview_time"), @@ -59,6 +61,13 @@ class Interview: "interview_status":payload.get("interview_status") or "", "inbox_id":payload.get("inbox_id"), } + inbox=await Inbox.get_inbox_with_message(self.session,payload.get("inbox_id")) + if inbox: + if inbox.user_id: + fields["user_id"]=inbox.user_id + msg=inbox.messages + if msg and msg.assigned_job_post_id: + fields["job_post_id"]=msg.assigned_job_post_id fields={k:v for k,v in fields.items() if v is not None} row=await Interviews.insert_interview(self.session,fields) when=row.interview_date or row.interview_time diff --git a/backend/job/job_post/enums.py b/backend/job/job_post/enums.py new file mode 100644 index 0000000..58fc815 --- /dev/null +++ b/backend/job/job_post/enums.py @@ -0,0 +1,47 @@ +from enum import Enum + + +class RequisitionStatus(str, Enum): + """Hiring lifecycle on job_posts.requisition_status. + + Distinct from job_posts.status, which is Buffer publish state + (draft/scheduled/published/failed). Values are the wire form the Jobs + screen already PATCHes; labels are what the dropdown renders. + """ + + OPEN = "open" + ON_HOLD = "on_hold" + CLOSED = "closed" + COMPLETED = "completed" + + @property + def label(self) -> str: + return _LABELS[self] + + @classmethod + def parse(cls, value): + """Accept the stored value or the UI label. None if neither matches.""" + raw = (value or "").strip() + if not raw: + return None + lowered = raw.lower().replace(" ", "_") + for member in cls: + if raw == member.value or lowered == member.value or raw == member.label: + return member + return None + + @classmethod + def values(cls) -> tuple[str, ...]: + return tuple(m.value for m in cls) + + @classmethod + def as_list(cls) -> list[dict]: + return [{"value": m.value, "label": m.label} for m in cls] + + +_LABELS = { + RequisitionStatus.OPEN: "Open", + RequisitionStatus.ON_HOLD: "On Hold", + RequisitionStatus.CLOSED: "Closed", + RequisitionStatus.COMPLETED: "Completed", +} diff --git a/backend/job/job_post/export.py b/backend/job/job_post/export.py index 0666f1d..4b7025a 100644 --- a/backend/job/job_post/export.py +++ b/backend/job/job_post/export.py @@ -20,8 +20,8 @@ BRAND_DARK = "0B3D2E" # header/banner green, matches the app chrome BRAND_STRIPE = "EFF7F2" # zebra row tint BORDER_TINT = "CBDCD2" -STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold"} -STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700"} +STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold", "completed": "Completed"} +STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700", "completed": "0F6E56"} # (header, column width) COLUMNS = [ diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 52f0fe2..6554eb0 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -2,10 +2,12 @@ import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING, Optional -from sqlalchemy import DateTime, JSON, func, or_ +from sqlalchemy import DateTime, JSON, Index, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, Relationship, SQLModel, select +from job.job_post.enums import RequisitionStatus + if TYPE_CHECKING: # runtime import would be circular: users.models imports this module from users.models import Users @@ -49,7 +51,7 @@ class JobPosts(SQLModel, table=True): buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) status: str = Field(default="draft") buffer_error: str | None = Field(default=None) - # requisition_status is the hiring lifecycle (open/closed/on_hold). Distinct from + # requisition_status is the hiring lifecycle (RequisitionStatus). Distinct from # `status`, which tracks Buffer publishing (draft/scheduled/published/failed). # server_default is load-bearing: this column arrives as an ALTER on a populated table. requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"}) @@ -211,10 +213,100 @@ class JobPosts(SQLModel, table=True): result = await session.execute(statement) return {uid: int(n or 0) for uid, n in result.all()} + @classmethod + async def count_by_current_recruiter( + cls, session: AsyncSession, recruiter_id, *, status, department=None, + from_date=None, to_date=None, + ): + """Requisitions owned by current_recruiter_id in one requisition_status.""" + uid = cls._as_uuid(recruiter_id) + if uid is None: + return 0 + statement = select(func.count()).select_from(cls).where( + cls.current_recruiter_id == uid, + cls.requisition_status == status, + cls.is_deleted == False, # noqa: E712 + ) + if department: + statement = statement.where(cls.department == department) + if from_date is not None: + statement = statement.where(cls.closed_at >= from_date) + if to_date is not None: + statement = statement.where(cls.closed_at < to_date) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + def _scoped(cls, statement, department=None, recruiter_id=None): + if department: + statement = statement.where(cls.department == department) + uid = cls._as_uuid(recruiter_id) if recruiter_id is not None else None + if uid is not None: + statement = statement.where(cls.current_recruiter_id == uid) + return statement + + @classmethod + async def count_requisitions( + cls, session: AsyncSession, status=None, department=None, recruiter_id=None, + from_date=None, to_date=None, *, closed_in_window=False, + ): + statement = select(func.count()).select_from(cls).where(cls.is_deleted == False) # noqa: E712 + if status: + statement = statement.where(cls.requisition_status == status) + statement = cls._scoped(statement, department, recruiter_id) + if closed_in_window: + if from_date is not None: + statement = statement.where(cls.closed_at >= from_date) + if to_date is not None: + statement = statement.where(cls.closed_at < to_date) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def count_open_snapshot(cls, session: AsyncSession, as_of, department=None, recruiter_id=None): + """Jobs that existed and were still open at `as_of` (best-effort).""" + statement = select(func.count()).select_from(cls).where( + cls.is_deleted == False, # noqa: E712 + cls.created_at < as_of, + or_(cls.closed_at.is_(None), cls.closed_at >= as_of), + cls.requisition_status == RequisitionStatus.OPEN.value, + ) + statement = cls._scoped(statement, department, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def avg_time_to_fill( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + days = func.extract("epoch", cls.closed_at - cls.created_at) / 86400.0 + statement = select(func.avg(days)).select_from(cls).where( + cls.is_deleted == False, # noqa: E712 + cls.requisition_status.in_(( + RequisitionStatus.CLOSED.value, + RequisitionStatus.COMPLETED.value, + )), + cls.closed_at.is_not(None), + ) + if from_date is not None: + statement = statement.where(cls.closed_at >= from_date) + if to_date is not None: + statement = statement.where(cls.closed_at < to_date) + statement = cls._scoped(statement, department, recruiter_id) + result = await session.execute(statement) + value = result.scalar_one() + return float(value) if value is not None else None + @classmethod async def insert_job_post(cls, session: AsyncSession, fields: dict): row = cls(**fields) session.add(row) + session.add(JobPostStatusHistory( + job_post_id=row.id, + from_status=None, + to_status=row.requisition_status or "open", + changed_by=row.created_by, + )) await session.commit() return await cls.get_job_post_by_id(session, row.id) @@ -289,23 +381,68 @@ class JobPosts(SQLModel, table=True): return row @classmethod - async def set_requisition_status(cls, session: AsyncSession, record_id: str, status: str): + async def set_requisition_status( + cls, session: AsyncSession, record_id: str, status: str, *, changed_by=None, + ): row = await cls.get_job_post_by_id(session, record_id) if not row or row.is_deleted: return None previous = row.requisition_status + if previous == status: + return row row.requisition_status = status - if status == "closed": - if previous != "closed" or row.closed_at is None: + terminal = status in ("closed", "completed") + if terminal: + if previous not in ("closed", "completed") or row.closed_at is None: row.closed_at = _now() else: row.closed_at = None row.updated_at = _now() session.add(row) + actor = cls._as_uuid(changed_by) if changed_by is not None else None + session.add(JobPostStatusHistory( + job_post_id=row.id, + from_status=previous, + to_status=status, + changed_by=actor, + )) await session.commit() return await cls.get_job_post_by_id(session, record_id) +class JobPostStatusHistory(SQLModel, table=True): + """Who changed job_posts.requisition_status, from what, to what, and when. + + Distinct from job_assignments (ownership intervals). The Jobs History tab + merges both. Applied on prod by migrations/manual/016_job_post_status_history.sql. + """ + + __tablename__ = "job_post_status_history" + __table_args__ = ( + Index("ix_job_post_status_history_job_created", "job_post_id", "created_at"), + ) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + job_post_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True) + from_status: str | None = Field(default=None) + to_status: str + changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + actor_kind: str = Field(default="user") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @classmethod + async def fetch_by_job(cls, session: AsyncSession, job_post_id): + uid = JobPosts._as_uuid(job_post_id) + if uid is None: + return [] + result = await session.execute( + select(cls) + .where(cls.job_post_id == uid) + .order_by(cls.created_at.desc(), cls.id.desc()) + ) + return list(result.scalars().all()) + + class JobPostImages(SQLModel, table=True): """Cover image of a job post, stored as bytes IN the database. diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 17b97ef..0610900 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -1,3 +1,13 @@ +from job.job_post.enums import RequisitionStatus + + +def _status_label(value): + if value in (None, ""): + return None + parsed = RequisitionStatus.parse(value) + return parsed.label if parsed else value + + def serialize_job_post(row) -> dict: return { "id": str(row.id), @@ -66,3 +76,18 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } + + +def serialize_status_history(row, *, changed_by_name=None) -> dict: + return { + "id": str(row.id), + "job_post_id": str(row.job_post_id) if row.job_post_id else None, + "from_status": row.from_status, + "from_label": _status_label(row.from_status), + "to_status": row.to_status, + "to_label": _status_label(row.to_status), + "changed_by": str(row.changed_by) if row.changed_by else None, + "changed_by_name": changed_by_name, + "actor_kind": row.actor_kind, + "created_at": row.created_at.isoformat() if row.created_at else None, + } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 18c06d0..a7b13dc 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -12,7 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator from inbox.models import Inbox_Messages from job.assignment.views import Assignment -from job.job_post.models import JobPostImages,JobPosts,SocialPlatform +from job.job_post.enums import RequisitionStatus +from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform from role.models import EnumRoles from users.models import Users from job.job_post.plugins import ( @@ -25,7 +26,7 @@ from job.job_post.plugins import ( render_job_post, resolve_channel, ) -from job.job_post.serializers import serialize_job_post, serialize_job_row +from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_status_history load_dotenv() logger=logging.getLogger("job.job_post") @@ -208,6 +209,20 @@ class JobPost: async def fetch_departments(self,active_only=False): return await JobPosts.list_departments(self.session,active_only=active_only) + async def fetch_requisition_statuses(self): + return RequisitionStatus.as_list() + + async def fetch_status_history(self,job_post_id): + job=await JobPosts.get_job_post_by_id(self.session,job_post_id) + if not job or job.is_deleted: + raise HTTPException(status_code=404,detail="Job post not found") + rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id) + names=await Users.names_by_ids(self.session,[r.changed_by for r in rows]) + return [ + serialize_status_history(r,changed_by_name=names.get(str(r.changed_by))) + for r in rows + ] + async def fetch_jobs(self,search=None,department=None,requisition_status=None, employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True): hm_uid=None @@ -352,13 +367,20 @@ class JobPost: if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") status=(payload.get("requisition_status") or "").strip() - allowed=("open","closed","on_hold") - if status not in allowed: - raise HTTPException(status_code=422,detail=f"requisition_status must be one of {', '.join(allowed)}") - row=await JobPosts.set_requisition_status(self.session,job_post_id,status) + parsed=RequisitionStatus.parse(status) + if parsed is None: + raise HTTPException( + status_code=422, + detail=f"requisition_status must be one of {', '.join(RequisitionStatus.values())}", + ) + status=parsed.value + actor=current_user.get("id") if isinstance(current_user,dict) else None + row=await JobPosts.set_requisition_status( + self.session,job_post_id,status,changed_by=actor, + ) if not row: raise HTTPException(status_code=404,detail="Job post not found") - if status=="closed": + if status==RequisitionStatus.CLOSED.value: try: from notifications.models import Notifications raw=row.current_recruiter_id or (current_user.get("id") if current_user else None) diff --git a/backend/migrations/manual/016_job_post_status_history.sql b/backend/migrations/manual/016_job_post_status_history.sql new file mode 100644 index 0000000..29a0ae5 --- /dev/null +++ b/backend/migrations/manual/016_job_post_status_history.sql @@ -0,0 +1,21 @@ +-- 016_job_post_status_history.sql +-- Audit log of job_posts.requisition_status changes (who, from, to, when). +-- The Jobs History tab merges this with job_assignments. Applied at startup +-- by alembic_setup.run_manual_sql(). Needed because prod boots with +-- DB_AUTOGENERATE=false. + +CREATE TABLE IF NOT EXISTS app.job_post_status_history ( + id UUID PRIMARY KEY, + job_post_id UUID NOT NULL REFERENCES app.job_posts(id), + from_status VARCHAR, + to_status VARCHAR NOT NULL, + changed_by UUID REFERENCES app.users(id), + actor_kind VARCHAR NOT NULL DEFAULT 'user', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_job_post_status_history_job_post_id + ON app.job_post_status_history (job_post_id); + +CREATE INDEX IF NOT EXISTS ix_job_post_status_history_job_created + ON app.job_post_status_history (job_post_id, created_at); diff --git a/backend/offer/models.py b/backend/offer/models.py index a6da752..f83a0bf 100644 --- a/backend/offer/models.py +++ b/backend/offer/models.py @@ -115,6 +115,38 @@ class Offers(SQLModel, table=True): result = await session.execute(statement) return result.scalar_one() + @classmethod + def scoped_to_job(cls, statement, department=None, recruiter_id=None): + if not department and not recruiter_id: + return statement + from job.job_post.models import JobPosts + statement = statement.outerjoin(JobPosts, cls.job_post_id == JobPosts.id) + if department: + statement = statement.where(JobPosts.department == department) + rid = cls._as_uuid(recruiter_id) + if rid is not None: + statement = statement.where(JobPosts.current_recruiter_id == rid) + return statement + + @classmethod + async def count_in_window( + cls, session: AsyncSession, statuses=None, from_date=None, to_date=None, + department=None, recruiter_id=None, *, exclude_draft=False, + ): + statement = select(func.count()).select_from(cls) + if exclude_draft: + statement = statement.where(cls.status != "draft") + elif statuses: + statement = statement.where(cls.status.in_(statuses)) + stamp = func.coalesce(cls.sent_at, cls.responded_at, cls.created_at) + if from_date is not None: + statement = statement.where(stamp >= from_date) + if to_date is not None: + statement = statement.where(stamp < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + class OfferStatusHistory(SQLModel, table=True): __tablename__ = "offer_status_history" diff --git a/backend/reports/runner.py b/backend/reports/runner.py index 6702d6f..321cb97 100644 --- a/backend/reports/runner.py +++ b/backend/reports/runner.py @@ -184,6 +184,7 @@ async def _build_recruiter_performance(session, f): row["avg_time_to_hire"] = _round(row.get("avg_time_to_hire")) columns = [ {"key": "name", "label": "Recruiter"}, + {"key": "completed", "label": "Completed Requisitions"}, {"key": "hires", "label": "Hires"}, {"key": "open_reqs", "label": "Open Requisitions"}, {"key": "avg_time_to_hire", "label": "Avg Time to Hire (days)"}, diff --git a/backend/users/models.py b/backend/users/models.py index 3eff965..eecf6b1 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -117,6 +117,23 @@ class Users(SQLModel, table=True): result = await session.execute(statement) return result.scalars().all() + @classmethod + async def list_by_role_name(cls, session: AsyncSession, role_name, user_id=None): + """Active (non-deleted) users whose Roles.role_name matches. Optional id filter.""" + statement = ( + select(cls) + .join(Roles, cls.role_id == Roles.id) + .where( + Roles.role_name == role_name, + cls.is_deleted == False, # noqa: E712 + ) + ) + uid = cls._as_uuid(user_id) if user_id is not None else None + if uid is not None: + statement = statement.where(cls.id == uid) + result = await session.execute(statement) + return list(result.scalars().all()) + @classmethod async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]: """Resolve {user_id: name} in a single query. diff --git a/frontend/src/api/interviews.js b/frontend/src/api/interviews.js index 73a3238..119a76d 100644 --- a/frontend/src/api/interviews.js +++ b/frontend/src/api/interviews.js @@ -35,12 +35,13 @@ export const INTERVIEW_TYPES = [ ] /** Range read. `top` is always sent so the route takes the range branch. */ -export function listRange({ fromDate, toDate, status, top = 200, skip } = {}) { +export function listRange({ fromDate, toDate, status, recruiterId, top = 200, skip } = {}) { return request('/interview/fetch', { params: { from_date: fromDate, to_date: toDate, status, + recruiter_id: recruiterId, top, skip, }, diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index bf6fa61..0b5b0c5 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -25,9 +25,22 @@ export function list({ search, department, requisitionStatus, employmentType, } /* requisition_status is the HIRING lifecycle. The row's separate `status` field is - the Buffer publishing lifecycle — never map the two onto one badge. */ -const REQ_STATUS_LABEL = { open: 'Open', closed: 'Closed', on_hold: 'On Hold' } -export const JOB_STATUSES = Object.values(REQ_STATUS_LABEL) + the Buffer publishing lifecycle — never map the two onto one badge. Fallback + matches GET /jobs/requisition-statuses/fetch so the dropdown still works if + that call 403s. */ +export const REQUISITION_STATUSES = [ + { value: 'open', label: 'Open' }, + { value: 'on_hold', label: 'On Hold' }, + { value: 'closed', label: 'Closed' }, + { value: 'completed', label: 'Completed' }, +] +const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label])) +const LABEL_TO_STATUS = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.label, s.value])) +export const JOB_STATUSES = REQUISITION_STATUSES.map((s) => s.label) + +export function listRequisitionStatuses() { + return request('/jobs/requisition-statuses/fetch') +} function experienceLabel(min, max) { if (min == null && max == null) return null @@ -66,8 +79,6 @@ export function toJobView(row) { } } -const LABEL_TO_STATUS = { Open: 'open', Closed: 'closed', 'On Hold': 'on_hold' } - /** * Styled .xlsx download of the requisition list — GET /jobs/export * (jobs.export). Same filters as list(); `status` takes the UI label. @@ -121,3 +132,8 @@ export function setStatus(jobPostId, status) { body: { requisition_status }, }) } + +/** Status-change audit for one requisition — GET /jobs/status-history/fetch. */ +export function listStatusHistory(jobPostId) { + return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } }) +} diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 9d845ec..e7eaa8a 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -79,6 +79,8 @@ export const qk = { jobs: { all: () => ['jobs'], list: (p = {}) => ['jobs', 'list', p], + requisitionStatuses: () => ['jobs', 'requisition-statuses'], + statusHistory: (id) => ['jobs', 'status-history', id], }, talent: { all: () => ['talent'], diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index e7aae69..7bf1f5d 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -28,7 +28,6 @@ import * as jobPostsApi from '../api/jobPosts' import * as assignmentsApi from '../api/assignments' import * as tasksApi from '../api/tasks' import * as usersApi from '../api/users' -import { JOB_STATUSES } from '../api/jobs' import { empTypes, fmtShort } from '../data/seed' // Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list). @@ -78,6 +77,18 @@ export default function Jobs() { const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data]) + const statusesQuery = useQuery({ + queryKey: qk.jobs.requisitionStatuses(), + queryFn: async () => { + const res = await jobsApi.listRequisitionStatuses() + const rows = Array.isArray(res?.data) ? res.data : [] + return rows.length ? rows : jobsApi.REQUISITION_STATUSES + }, + }) + const statusLabels = useMemo( + () => (statusesQuery.data ?? jobsApi.REQUISITION_STATUSES).map((s) => s.label), + [statusesQuery.data], + ) const [q, setQ] = useState('') const [dept, setDept] = useState('') @@ -92,12 +103,19 @@ export default function Jobs() { const canDelete = can('jobs.delete') // Deep-link intents from global search, the dashboard and the manager portal. + // Consume once and replace history state: jobs refetch after a status PATCH + // used to replay openCreate and pop the create modal over the detail view. useEffect(() => { const st = location.state - if (!st) return + if (!st?.openCreate && !st?.openJob) return if (st.openCreate) setCreating(true) - if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null) - }, [location.state, jobs]) + if (st.openJob) { + const job = jobs.find((j) => j.id === st.openJob) + if (job) setViewing(job) + else if (!jobsQuery.isSuccess) return + } + navigate('.', { replace: true, state: null }) + }, [location.state, jobs, jobsQuery.isSuccess, navigate]) useEffect(() => { if (!viewing) return @@ -239,17 +257,17 @@ export default function Jobs() { key: '_a', label: 'Actions', align: 'right', render: (j) => (
- + {canEdit && ( - + )} - {canEdit && (j.status === 'Closed' ? ( + {canEdit && (j.status === 'Closed' || j.status === 'Completed' ? ( ) : ( ))} - +
), }, @@ -314,7 +333,7 @@ export default function Jobs() { ) : ( {j.status} @@ -1173,7 +1264,7 @@ function JobDetail({ onChange={setTab} tabs={[ { key: 'details', label: 'Details' }, - { key: 'history', label: 'History', count: historyQuery.data?.length }, + { key: 'history', label: 'History', count: historyCount || undefined }, ]} /> @@ -1213,7 +1304,7 @@ function JobDetail({ )} - {tab === 'history' && } + {tab === 'history' && } ) } diff --git a/frontend/src/screens/RecruiterHub.jsx b/frontend/src/screens/RecruiterHub.jsx index 700ce38..a850186 100644 --- a/frontend/src/screens/RecruiterHub.jsx +++ b/frontend/src/screens/RecruiterHub.jsx @@ -24,6 +24,11 @@ is the worklist the prototype filed under Recruiter Hub. Completing a row writes the same /tasks/update the Tasks screen uses, so the two stay in sync. Hidden without tasks.view; the rest of the hub still loads. + + Interviews Today / upcoming / the heatmap join interviews → job_posts via + COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id) and + filter on current_recruiter_id. The leaderboard ranks by completed + requisitions (requisition_status=completed), not inbox hires. ============================================================ */ import { useMemo, useState } from 'react' @@ -143,16 +148,18 @@ export default function RecruiterHub() { }, []) const heatQuery = useQuery({ - queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS }), + queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS, recruiterId: activeId }), queryFn: async () => { const res = await interviewsApi.listRange({ fromDate: heatFrom.toISOString(), toDate: new Date().toISOString(), + recruiterId: activeId, top: 500, }) const rows = Array.isArray(res?.data) ? res.data : [] return rows.map(interviewsApi.toInterviewView) }, + enabled: Boolean(activeId), }) const heatmap = useMemo(() => { @@ -191,7 +198,7 @@ export default function RecruiterHub() { }, [funnelQuery.data]) const board = useMemo( - () => [...recruiters].sort((a, b) => (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8), + () => [...recruiters].sort((a, b) => (b.completed ?? 0) - (a.completed ?? 0) || (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8), [recruiters], ) @@ -269,6 +276,8 @@ export default function RecruiterHub() {
{selected.open_reqs ?? 0} open req{(selected.open_reqs ?? 0) === 1 ? '' : 's'} {' · '} + {selected.completed ?? 0} completed + {' · '} {selected.hires ?? 0} hire{(selected.hires ?? 0) === 1 ? '' : 's'} {canViewTasks && !tasksLoading ? ( <> @@ -364,7 +373,7 @@ export default function RecruiterHub() {

Interview Load

- Team-wide, last {WEEKS} weeks + This recruiter, last {WEEKS} weeks
@@ -403,7 +412,7 @@ export default function RecruiterHub() { More

- Interviews carry no recruiter, so this counts the whole team. + Counted from interviews on this recruiter’s jobs.

)} @@ -414,12 +423,12 @@ export default function RecruiterHub() {
-

Recruiter Leaderboard

Top performers by hires
+

Recruiter Leaderboard

Ranked by completed requisitions
{board.length === 0 ? ( - - The board fills in as applications reach the hired stage. + + Mark a job Completed when hiring finishes to rank recruiters here. ) : ( board.map((rec, i) => { @@ -445,8 +454,8 @@ export default function RecruiterHub() {
-
{rec.hires ?? 0}
-
hires
+
{rec.completed ?? 0}
+
completed
) diff --git a/frontend/src/ui/DataTable.jsx b/frontend/src/ui/DataTable.jsx index 750bdbc..20c2c87 100644 --- a/frontend/src/ui/DataTable.jsx +++ b/frontend/src/ui/DataTable.jsx @@ -248,7 +248,10 @@ export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE, key={row.id ?? i} className={onRowClick ? 'row-click' : undefined} tabIndex={onRowClick ? 0 : undefined} - onClick={onRowClick ? () => onRowClick(row) : undefined} + onClick={onRowClick ? (e) => { + if (e.target.closest('button, a, select, input, textarea, label, .row-actions')) return + onRowClick(row) + } : undefined} onKeyDown={onRowClick ? (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row) } : undefined}