recruiterhub activity improvement

pull/34/head
ahmed.mujtaba 2026-08-30 23:24:00 +05:00
parent 9dadcb4698
commit 4b84ae4a30
23 changed files with 1028 additions and 487 deletions

View File

@ -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,
}

View File

@ -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<to_date)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_open_snapshot(self,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(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.created_at<as_of,
or_(JobPosts.closed_at.is_(None),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<to_date)
# Best-effort department/recruiter via linked message → job post
if department or recruiter_id:
statement=(
statement
.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=_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 self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_offers(self,statuses,from_date=None,to_date=None,department=None,recruiter_id=None,*,exclude_draft=False):
statement=select(func.count()).select_from(Offers)
if exclude_draft:
statement=statement.where(Offers.status!="draft")
elif statuses:
statement=statement.where(Offers.status.in_(statuses))
stamp=func.coalesce(Offers.sent_at,Offers.responded_at,Offers.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)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Offers.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(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_hires(self,from_date=None,to_date=None,department=None,recruiter_id=None):
# Prefer HIRED transitions in window; fall back path uses inbox_messages status.
hired=ApplicationStageTransitions
statement=select(func.count()).select_from(hired).where(hired.to_stage==Candidate_application_Status.HIRED.value)
if from_date is not None:
statement=statement.where(hired.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hired.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hired.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=_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 self.session.execute(statement)
count=int(result.scalar_one() or 0)
count=await ApplicationStageTransitions.count_hires(
self.session,from_date=from_date,to_date=to_date,
department=department,recruiter_id=recruiter_id,
)
if count:
return count
# Fallback: messages currently HIRED, windowed via inbox.created_at
msg=(
select(func.count())
.select_from(Inbox_Messages)
.join(Inbox,Inbox.message_id==Inbox_Messages.id)
.where(Inbox_Messages.application_status==Candidate_application_Status.HIRED)
return await Inbox_Messages.count_hired(
self.session,from_date=from_date,to_date=to_date,
department=department,recruiter_id=recruiter_id,
)
if from_date is not None:
msg=msg.where(Inbox.created_at>=from_date)
if to_date is not None:
msg=msg.where(Inbox.created_at<to_date)
if department or recruiter_id:
msg=msg.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
msg=msg.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
msg=msg.where(or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid))
result=await self.session.execute(msg)
return int(result.scalar_one() or 0)
async def _avg_time_to_hire(self,from_date=None,to_date=None,department=None,recruiter_id=None):
entry=ApplicationStageTransitions.__table__.alias("entry")
hire=ApplicationStageTransitions.__table__.alias("hire")
days=_days_expr(hire.c.valid_from,entry.c.valid_from)
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=_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 self.session.execute(statement)
value=result.scalar_one()
return float(value) if value is not None else None
async def _avg_time_to_fill(self,from_date=None,to_date=None,department=None,recruiter_id=None):
days=_days_expr(JobPosts.closed_at,JobPosts.created_at)
statement=select(func.avg(days)).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.requisition_status=="closed",
JobPosts.closed_at.is_not(None),
)
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<to_date)
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)
value=result.scalar_one()
return float(value) if value is not None else None
async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None):
if not hires:
return None
statement=select(func.coalesce(func.sum(HiringCosts.amount),0.0))
if from_date is not None:
statement=statement.where(HiringCosts.incurred_at>=from_date)
if to_date is not None:
statement=statement.where(HiringCosts.incurred_at<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,HiringCosts.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(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
total=float(result.scalar_one() or 0.0)
total=await HiringCosts.sum_amount(
self.session,from_date=from_date,to_date=to_date,
department=department,recruiter_id=recruiter_id,
)
return total/hires
async def get_kpis(self,from_date=None,to_date=None,department=None,recruiter_id=None):
@ -297,58 +96,72 @@ class Analytics:
today_start=datetime(now.year,now.month,now.day,tzinfo=timezone.utc)
tomorrow=today_start+timedelta(days=1)
open_jobs=await self._count_jobs("open",department=department,recruiter_id=recruiter_id)
open_jobs_prior=await self._count_open_snapshot(window_from,department=department,recruiter_id=recruiter_id)
closed_jobs=await self._count_jobs(
"closed",window_from,window_to,department,recruiter_id,closed_in_window=True
open_jobs=await JobPosts.count_requisitions(
self.session,status="open",department=department,recruiter_id=recruiter_id,
)
closed_jobs_prior=await self._count_jobs(
"closed",prior_from,prior_to,department,recruiter_id,closed_in_window=True
open_jobs_prior=await JobPosts.count_open_snapshot(
self.session,window_from,department=department,recruiter_id=recruiter_id,
)
total_candidates=await self._count_candidates(window_from,window_to,department,recruiter_id)
total_candidates_prior=await self._count_candidates(prior_from,prior_to,department,recruiter_id)
interviews_today_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_date>=today_start,
Interviews.interview_date<tomorrow,
closed_jobs=await JobPosts.count_requisitions(
self.session,status="closed",department=department,recruiter_id=recruiter_id,
from_date=window_from,to_date=window_to,closed_in_window=True,
)
interviews_today=int((await self.session.execute(interviews_today_q)).scalar_one() or 0)
upcoming_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_status.ilike("scheduled"),
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<to_date)
statement=statement.group_by(Inbox_Messages.application_status)
result=await self.session.execute(statement)
counts={str(row[0].value if hasattr(row[0],"value") else row[0]): int(row[1] or 0) for row in result.all()}
counts=await Inbox_Messages.counts_by_application_status(
self.session,from_date=from_date,to_date=to_date,
department=department,recruiter_id=recruiter_id,
)
return [
serialize_stage_count(stage.value,counts.get(stage.value,0))
for stage in Candidate_application_Status
@ -428,65 +223,20 @@ class Analytics:
months=max(1,int(months or 7))
now=datetime.now(timezone.utc)
start=_month_start(now)
# Walk back (months-1) months
for _ in range(months-1):
start=_month_start(start-timedelta(days=1))
month_bucket=func.date_trunc("month",Inbox.created_at)
apps_q=(
select(month_bucket.label("month"),func.count().label("count"))
.select_from(Inbox)
.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<to_date)
statement=statement.group_by(SourceChannels.id,SourceChannels.label).order_by(func.count().desc())
result=await self.session.execute(statement)
rows=result.all()
# REQ-ANL-09 cost side: spend explicitly tagged to a source channel in the
# cost ledger. Untagged spend is deliberately excluded — it belongs to
# cost-per-hire, and folding it into "Unknown" would fabricate a ROI figure.
spend_q=select(
HiringCosts.source_channel_id,
func.coalesce(func.sum(HiringCosts.amount),0.0),
).where(HiringCosts.source_channel_id.is_not(None))
if from_date is not None:
spend_q=spend_q.where(HiringCosts.incurred_at>=from_date)
if to_date is not None:
spend_q=spend_q.where(HiringCosts.incurred_at<to_date)
if department or recruiter_id:
spend_q=spend_q.outerjoin(JobPosts,HiringCosts.job_post_id==JobPosts.id)
if department:
spend_q=spend_q.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
spend_q=spend_q.where(JobPosts.current_recruiter_id==rid)
spend_q=spend_q.group_by(HiringCosts.source_channel_id)
spend_map={
channel_id:float(total or 0.0)
for channel_id,total in (await self.session.execute(spend_q)).all()
}
spend_map=await HiringCosts.sum_by_source_channel(
self.session,from_date=from_date,to_date=to_date,
department=department,recruiter_id=recruiter_id,
)
# A channel with tagged spend but zero applications must still get a row:
# spend that produced nothing is the strongest ROI signal this table has,
@ -558,9 +269,9 @@ class Analytics:
present={source_id for source_id,_,_ in rows}
missing=[cid for cid in spend_map if cid not in present]
if missing:
channels_q=select(SourceChannels.id,SourceChannels.label).where(SourceChannels.id.in_(missing))
rows=list(rows)+[
(cid,label,0) for cid,label in (await self.session.execute(channels_q)).all()
(cid,label,0)
for cid,label in await SourceChannels.labels_by_ids(self.session,missing)
]
return [
@ -572,52 +283,31 @@ class Analytics:
async def get_recruiter_performance(self,top=5,from_date=None,to_date=None,department=None,recruiter_id=None):
top=max(1,int(top or 5))
recruiters_q=(
select(Users)
.join(Roles,Users.role_id==Roles.id)
.where(
Roles.role_name==EnumRoles.RECRUITER.value,
Users.is_deleted==False, # noqa: E712
)
recruiters=await Users.list_by_role_name(
self.session,EnumRoles.RECRUITER.value,user_id=recruiter_id,
)
rid=_as_uuid(recruiter_id)
if rid is not None:
recruiters_q=recruiters_q.where(Users.id==rid)
recruiters=list((await self.session.execute(recruiters_q)).scalars().all())
rows=[]
for user in recruiters:
hires_q=select(func.count()).select_from(Inbox_Messages).where(
Inbox_Messages.recruiter_id==user.id,
Inbox_Messages.application_status==Candidate_application_Status.HIRED,
hires=await Inbox_Messages.count_hires_by_recruiter(
self.session,user.id,from_date=from_date,to_date=to_date,department=department,
)
if department:
hires_q=hires_q.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id).where(
JobPosts.department==department
)
if from_date is not None or to_date is not None:
hires_q=hires_q.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
hires_q=hires_q.where(Inbox.created_at>=from_date)
if to_date is not None:
hires_q=hires_q.where(Inbox.created_at<to_date)
hires=int((await self.session.execute(hires_q)).scalar_one() or 0)
open_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id)
open_posts_q=select(func.count()).select_from(JobPosts).where(
JobPosts.current_recruiter_id==user.id,
JobPosts.requisition_status=="open",
JobPosts.is_deleted==False, # noqa: E712
open_posts=await JobPosts.count_by_current_recruiter(
self.session,user.id,status=RequisitionStatus.OPEN.value,department=department,
)
if department:
open_posts_q=open_posts_q.where(JobPosts.department==department)
open_posts=int((await self.session.execute(open_posts_q)).scalar_one() or 0)
open_reqs=max(open_assign,open_posts)
avg_tth=await self._avg_time_to_hire(
from_date,to_date,department,recruiter_id=str(user.id)
completed=await JobPosts.count_by_current_recruiter(
self.session,user.id,status=RequisitionStatus.COMPLETED.value,
department=department,from_date=from_date,to_date=to_date,
)
rows.append(serialize_recruiter_row(user.id,user.name,hires,open_reqs,avg_tth))
avg_tth=await ApplicationStageTransitions.avg_time_to_hire(
self.session,from_date=from_date,to_date=to_date,
department=department,recruiter_id=str(user.id),
)
rows.append(serialize_recruiter_row(
user.id,user.name,hires,open_reqs,avg_tth,completed=completed,
))
rows.sort(key=lambda r: r["hires"],reverse=True)
rows.sort(key=lambda r: (r["completed"], r["hires"]),reverse=True)
return rows[:top]

View File

@ -259,6 +259,64 @@ class Inbox(SQLModel, table=True):
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@classmethod
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
"""Optional department / recruiter via inbox_messages → job_posts."""
if not department and not recruiter_id:
return statement
from job.job_post.models import JobPosts
statement = (
statement
.outerjoin(Inbox_Messages, cls.message_id == Inbox_Messages.id)
.outerjoin(JobPosts, Inbox_Messages.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_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
)
return statement
@classmethod
async def count_in_window(
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
):
statement = (
select(func.count())
.select_from(cls)
.join(Users, cls.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(cls.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"

View File

@ -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

View File

@ -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)

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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",
}

View File

@ -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 = [

View File

@ -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.

View File

@ -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,
}

View File

@ -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)

View File

@ -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);

View File

@ -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"

View File

@ -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)"},

View File

@ -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.

View File

@ -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,
},

View File

@ -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 } })
}

View File

@ -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'],

View File

@ -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) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" aria-label="View job" onClick={() => setViewing(j)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); setViewing(j) }}><Icon name="eye" /></button>
{canEdit && (
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={() => setEditing(j)}><Icon name="edit" /></button>
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button>
)}
{canEdit && (j.status === 'Closed' ? (
{canEdit && (j.status === 'Closed' || j.status === 'Completed' ? (
<button
className="act-btn"
data-tip="Reopen"
aria-label="Reopen job"
disabled={setJobStatus.isPending}
onClick={() => setJobStatus.mutate({ id: j.id, status: 'Open' })}
onClick={(e) => { e.stopPropagation(); setJobStatus.mutate({ id: j.id, status: 'Open' }) }}
><Icon name="refresh" /></button>
) : (
<button
@ -257,14 +275,15 @@ export default function Jobs() {
data-tip="Close job"
aria-label="Close job"
disabled={setJobStatus.isPending}
onClick={() => {
onClick={(e) => {
e.stopPropagation()
if (window.confirm(`Close “${j.title}”? It stays on the board and can be reopened later.`)) {
setJobStatus.mutate({ id: j.id, status: 'Closed' })
}
}}
><Icon name="x-circle" /></button>
))}
<button className="act-btn" data-tip="Publish" aria-label="Publish job" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
<button className="act-btn" data-tip="Publish" aria-label="Publish job" onClick={(e) => { e.stopPropagation(); navigate('/jobboard', { state: { publishJob: j.id } }) }}><Icon name="send" /></button>
</div>
),
},
@ -314,7 +333,7 @@ export default function Jobs() {
</select>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
{statusLabels.map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
@ -327,6 +346,7 @@ export default function Jobs() {
rows={rows}
pageSize={8}
empty="No requisitions match these filters."
onRowClick={(j) => setViewing(j)}
/>
</>
)}
@ -343,6 +363,7 @@ export default function Jobs() {
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
onEdit={() => { setEditing(viewing); setViewing(null) }}
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
statusLabels={statusLabels}
onDelete={() => {
if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id)
}}
@ -380,6 +401,13 @@ const IMAGE_ACCEPT = 'image/png,image/jpeg,image/jpg,image/webp,image/gif,.png,.
const MAX_IMAGE_MB = 5
const ROLE_LABEL = { primary_recruiter: 'Recruiter', hiring_manager: 'Hiring manager' }
function fmtWhen(value) {
const d = value instanceof Date ? value : new Date(value ?? NaN)
if (Number.isNaN(d.getTime())) return null
const clock = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
return `${fmtShort(d)} · ${clock}`
}
/**
* Searchable picker: type to filter, click a row to store the id.
* Not free-text the value is always an option id (or '' when allowEmpty).
@ -1047,44 +1075,96 @@ function JobOwnership({ job, canEdit }) {
)
}
function AssignmentHistory({ historyQuery }) {
const history = historyQuery.data ?? []
function JobHistory({ historyQuery, statusQuery }) {
const assignments = historyQuery.data ?? []
const statusRows = statusQuery.data ?? []
if (historyQuery.isError) {
if (historyQuery.isError && statusQuery.isError) {
return (
<p className="text-muted text-sm">
{friendlyAuthError(historyQuery.error, 'History did not load.')}
</p>
)
}
if (historyQuery.isPending) {
if ((historyQuery.isPending && !historyQuery.data) || (statusQuery.isPending && !statusQuery.data)) {
return <p className="text-muted text-sm">Loading</p>
}
if (history.length === 0) {
return <p className="text-muted">No assignment history yet.</p>
const events = [
...assignments.map((row) => ({
kind: 'assignment',
id: `a-${row.id}`,
at: row.validFrom,
row,
})),
...statusRows.map((row) => ({
kind: 'status',
id: `s-${row.id}`,
at: row.created_at ? new Date(row.created_at) : null,
row,
})),
].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0))
if (events.length === 0) {
return <p className="text-muted">No history yet.</p>
}
return (
<div className="list-tight">
{history.map((row) => (
<div className="list-row" key={row.id}>
<div className="lr-main">
<div className="lr-title">{row.name || 'Unknown'}</div>
<div className="lr-sub">
{[
ROLE_LABEL[row.role] || row.role?.replace(/_/g, ' '),
row.validFrom ? fmtShort(row.validFrom) : null,
row.validTo ? `${fmtShort(row.validTo)}` : 'current',
row.assignedByName ? `by ${row.assignedByName}` : null,
].filter(Boolean).join(' · ')}
</div>
</div>
{!row.validTo && <Badge className="b-green">Current</Badge>}
</div>
{events.map((ev) => (
ev.kind === 'status'
? <StatusHistoryRow key={ev.id} row={ev.row} at={ev.at} />
: <AssignmentHistoryRow key={ev.id} row={ev.row} />
))}
</div>
)
}
function StatusHistoryRow({ row, at }) {
const fromLabel = row.from_label || row.from_status
const toLabel = row.to_label || row.to_status || '—'
const actor = row.changed_by_name || (row.changed_by ? 'Unknown' : 'System')
const title = fromLabel ? 'Status changed' : `Opened as ${toLabel}`
const change = fromLabel ? `${fromLabel}${toLabel}` : null
return (
<div className="list-row">
<span className="kpi-icn i-indigo" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="refresh" />
</span>
<div className="lr-main">
<div className="lr-title">{title}</div>
{change && <div className="lr-sub">{change}</div>}
<div className="lr-sub">
{[actor, at ? fmtWhen(at) : null].filter(Boolean).join(' · ')}
</div>
</div>
<Badge>{toLabel}</Badge>
</div>
)
}
function AssignmentHistoryRow({ row }) {
return (
<div className="list-row">
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="user" />
</span>
<div className="lr-main">
<div className="lr-title">{row.name || 'Unassigned'}</div>
<div className="lr-sub">
{[
ROLE_LABEL[row.role] || row.role?.replace(/_/g, ' '),
row.validFrom ? fmtShort(row.validFrom) : null,
row.validTo ? `${fmtShort(row.validTo)}` : 'current',
row.assignedByName ? `by ${row.assignedByName}` : null,
].filter(Boolean).join(' · ')}
</div>
</div>
{!row.validTo && <Badge className="b-green">Current</Badge>}
</div>
)
}
/* Cover image, when the post has one fetched with the bearer token into an
object URL, because a bare <img src> cannot carry auth headers. null (404)
simply renders nothing. */
@ -1108,6 +1188,7 @@ function JobCover({ jobId }) {
function JobDetail({
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
statusLabels = jobsApi.JOB_STATUSES,
}) {
const [tab, setTab] = useState('details')
const historyQuery = useQuery({
@ -1120,6 +1201,16 @@ function JobDetail({
enabled: Boolean(j.id),
retry: false,
})
const statusQuery = useQuery({
queryKey: qk.jobs.statusHistory(j.id),
queryFn: async () => {
const res = await jobsApi.listStatusHistory(j.id)
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(j.id),
retry: false,
})
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0)
return (
<Modal
@ -1160,7 +1251,7 @@ function JobDetail({
disabled={statusBusy}
onChange={(e) => onStatus(e.target.value)}
>
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
{statusLabels.map((s) => <option key={s}>{s}</option>)}
</select>
) : (
<Badge>{j.status}</Badge>
@ -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' && <AssignmentHistory historyQuery={historyQuery} />}
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} />}
</Modal>
)
}

View File

@ -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() {
<div style={{ opacity: 0.85 }}>
{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() {
<div className="card-head">
<div>
<h3>Interview Load</h3>
<span className="ch-sub">Team-wide, last {WEEKS} weeks</span>
<span className="ch-sub">This recruiter, last {WEEKS} weeks</span>
</div>
</div>
<div className="card-body">
@ -403,7 +412,7 @@ export default function RecruiterHub() {
More
</div>
<p className="text-muted" style={{ marginTop: 10, fontSize: 12 }}>
<Icon name="info" /> Interviews carry no recruiter, so this counts the whole team.
<Icon name="info" /> Counted from interviews on this recruiters jobs.
</p>
</>
)}
@ -414,12 +423,12 @@ export default function RecruiterHub() {
<div className="grid g-2">
<div className="card">
<div className="card-head">
<div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Top performers by hires</span></div>
<div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Ranked by completed requisitions</span></div>
</div>
<div className="card-body">
{board.length === 0 ? (
<EmptyState icon="users" title="No hires recorded yet">
The board fills in as applications reach the hired stage.
<EmptyState icon="users" title="No completed requisitions yet">
Mark a job Completed when hiring finishes to rank recruiters here.
</EmptyState>
) : (
board.map((rec, i) => {
@ -445,8 +454,8 @@ export default function RecruiterHub() {
</div>
</div>
<div className="lr-right">
<div className="fw-600">{rec.hires ?? 0}</div>
<div className="lr-sub">hires</div>
<div className="fw-600">{rec.completed ?? 0}</div>
<div className="lr-sub">completed</div>
</div>
</div>
)

View File

@ -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}