recruiterhub activity improvement
parent
9dadcb4698
commit
4b84ae4a30
|
|
@ -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 {
|
return {
|
||||||
"id": str(user_id) if user_id else None,
|
"id": str(user_id) if user_id else None,
|
||||||
"name": name,
|
"name": name,
|
||||||
"hires": int(hires or 0),
|
"hires": int(hires or 0),
|
||||||
|
"completed": int(completed or 0),
|
||||||
"open_reqs": int(open_reqs 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,
|
"avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import uuid
|
|
||||||
from datetime import datetime,timedelta,timezone
|
from datetime import datetime,timedelta,timezone
|
||||||
|
|
||||||
from sqlalchemy import and_,func,or_,select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from analytics.serializers import (
|
from analytics.serializers import (
|
||||||
|
|
@ -14,22 +12,14 @@ from inbox.models import Inbox,Inbox_Messages,SourceChannels
|
||||||
from job.assignment.models import JobAssignments
|
from job.assignment.models import JobAssignments
|
||||||
from job.candidate.models import ApplicationStageTransitions,Interviews
|
from job.candidate.models import ApplicationStageTransitions,Interviews
|
||||||
from job.cost.models import HiringCosts
|
from job.cost.models import HiringCosts
|
||||||
|
from job.job_post.enums import RequisitionStatus
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
from offer.models import Offers
|
from offer.models import Offers
|
||||||
from org_settings.models import OrgSettings
|
from org_settings.models import OrgSettings
|
||||||
from role.models import EnumRoles,Roles
|
from role.models import EnumRoles
|
||||||
from users.models import Users
|
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:
|
def _month_start(dt: datetime) -> datetime:
|
||||||
return datetime(dt.year,dt.month,1,tzinfo=timezone.utc)
|
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)
|
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:
|
class Analytics:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
self.session=session
|
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):
|
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.
|
count=await ApplicationStageTransitions.count_hires(
|
||||||
hired=ApplicationStageTransitions
|
self.session,from_date=from_date,to_date=to_date,
|
||||||
statement=select(func.count()).select_from(hired).where(hired.to_stage==Candidate_application_Status.HIRED.value)
|
department=department,recruiter_id=recruiter_id,
|
||||||
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)
|
|
||||||
if count:
|
if count:
|
||||||
return count
|
return count
|
||||||
# Fallback: messages currently HIRED, windowed via inbox.created_at
|
return await Inbox_Messages.count_hired(
|
||||||
msg=(
|
self.session,from_date=from_date,to_date=to_date,
|
||||||
select(func.count())
|
department=department,recruiter_id=recruiter_id,
|
||||||
.select_from(Inbox_Messages)
|
|
||||||
.join(Inbox,Inbox.message_id==Inbox_Messages.id)
|
|
||||||
.where(Inbox_Messages.application_status==Candidate_application_Status.HIRED)
|
|
||||||
)
|
)
|
||||||
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):
|
async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||||
if not hires:
|
if not hires:
|
||||||
return None
|
return None
|
||||||
statement=select(func.coalesce(func.sum(HiringCosts.amount),0.0))
|
total=await HiringCosts.sum_amount(
|
||||||
if from_date is not None:
|
self.session,from_date=from_date,to_date=to_date,
|
||||||
statement=statement.where(HiringCosts.incurred_at>=from_date)
|
department=department,recruiter_id=recruiter_id,
|
||||||
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)
|
|
||||||
return total/hires
|
return total/hires
|
||||||
|
|
||||||
async def get_kpis(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
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)
|
today_start=datetime(now.year,now.month,now.day,tzinfo=timezone.utc)
|
||||||
tomorrow=today_start+timedelta(days=1)
|
tomorrow=today_start+timedelta(days=1)
|
||||||
|
|
||||||
open_jobs=await self._count_jobs("open",department=department,recruiter_id=recruiter_id)
|
open_jobs=await JobPosts.count_requisitions(
|
||||||
open_jobs_prior=await self._count_open_snapshot(window_from,department=department,recruiter_id=recruiter_id)
|
self.session,status="open",department=department,recruiter_id=recruiter_id,
|
||||||
|
|
||||||
closed_jobs=await self._count_jobs(
|
|
||||||
"closed",window_from,window_to,department,recruiter_id,closed_in_window=True
|
|
||||||
)
|
)
|
||||||
closed_jobs_prior=await self._count_jobs(
|
open_jobs_prior=await JobPosts.count_open_snapshot(
|
||||||
"closed",prior_from,prior_to,department,recruiter_id,closed_in_window=True
|
self.session,window_from,department=department,recruiter_id=recruiter_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
total_candidates=await self._count_candidates(window_from,window_to,department,recruiter_id)
|
closed_jobs=await JobPosts.count_requisitions(
|
||||||
total_candidates_prior=await self._count_candidates(prior_from,prior_to,department,recruiter_id)
|
self.session,status="closed",department=department,recruiter_id=recruiter_id,
|
||||||
|
from_date=window_from,to_date=window_to,closed_in_window=True,
|
||||||
interviews_today_q=select(func.count()).select_from(Interviews).where(
|
|
||||||
Interviews.interview_date>=today_start,
|
|
||||||
Interviews.interview_date<tomorrow,
|
|
||||||
)
|
)
|
||||||
interviews_today=int((await self.session.execute(interviews_today_q)).scalar_one() or 0)
|
closed_jobs_prior=await JobPosts.count_requisitions(
|
||||||
|
self.session,status="closed",department=department,recruiter_id=recruiter_id,
|
||||||
upcoming_q=select(func.count()).select_from(Interviews).where(
|
from_date=prior_from,to_date=prior_to,closed_in_window=True,
|
||||||
Interviews.interview_status.ilike("scheduled"),
|
|
||||||
Interviews.interview_date>=now,
|
|
||||||
)
|
)
|
||||||
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(
|
total_candidates=await Inbox.count_in_window(
|
||||||
Interviews.interview_status.ilike("scheduled"),
|
self.session,window_from,window_to,department,recruiter_id,
|
||||||
Interviews.interview_date>=now,
|
)
|
||||||
|
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
|
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=await Offers.count_in_window(
|
||||||
offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id)
|
self.session,["accepted"],window_from,window_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_sent_prior=await self._count_offers(
|
offers_accepted_prior=await Offers.count_in_window(
|
||||||
["sent","negotiating","accepted","declined","expired"],
|
self.session,["accepted"],prior_from,prior_to,department,recruiter_id,
|
||||||
prior_from,prior_to,department,recruiter_id,exclude_draft=True,
|
)
|
||||||
|
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=await self._count_hires(window_from,window_to,department,recruiter_id)
|
||||||
hires_prior=await self._count_hires(prior_from,prior_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=await ApplicationStageTransitions.avg_time_to_hire(
|
||||||
time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id)
|
self.session,window_from,window_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_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=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
|
# 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
|
# ({"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):
|
async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||||
statement=select(
|
counts=await Inbox_Messages.counts_by_application_status(
|
||||||
Inbox_Messages.application_status,
|
self.session,from_date=from_date,to_date=to_date,
|
||||||
func.count().label("count"),
|
department=department,recruiter_id=recruiter_id,
|
||||||
).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()}
|
|
||||||
return [
|
return [
|
||||||
serialize_stage_count(stage.value,counts.get(stage.value,0))
|
serialize_stage_count(stage.value,counts.get(stage.value,0))
|
||||||
for stage in Candidate_application_Status
|
for stage in Candidate_application_Status
|
||||||
|
|
@ -428,65 +223,20 @@ class Analytics:
|
||||||
months=max(1,int(months or 7))
|
months=max(1,int(months or 7))
|
||||||
now=datetime.now(timezone.utc)
|
now=datetime.now(timezone.utc)
|
||||||
start=_month_start(now)
|
start=_month_start(now)
|
||||||
# Walk back (months-1) months
|
|
||||||
for _ in range(months-1):
|
for _ in range(months-1):
|
||||||
start=_month_start(start-timedelta(days=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={}
|
apps_map={}
|
||||||
for month,count in apps_rows.all():
|
for month,count in await Inbox.counts_by_month(
|
||||||
apps_map[_month_key(month)]=int(count or 0)
|
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={}
|
hire_map={}
|
||||||
for month,count in hire_rows.all():
|
for month,count in await ApplicationStageTransitions.counts_hires_by_month(
|
||||||
hire_map[_month_key(month)]=int(count or 0)
|
self.session,start,department=department,recruiter_id=recruiter_id,
|
||||||
|
):
|
||||||
|
hire_map[_month_key(month)]=count
|
||||||
|
|
||||||
labels=[]
|
labels=[]
|
||||||
applications=[]
|
applications=[]
|
||||||
|
|
@ -500,57 +250,18 @@ class Analytics:
|
||||||
return {"labels": labels,"applications": applications,"hires": hires}
|
return {"labels": labels,"applications": applications,"hires": hires}
|
||||||
|
|
||||||
async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||||
statement=(
|
rows=await Inbox_Messages.counts_by_source(
|
||||||
select(
|
self.session,from_date=from_date,to_date=to_date,
|
||||||
SourceChannels.id.label("source_id"),
|
department=department,recruiter_id=recruiter_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)
|
|
||||||
)
|
|
||||||
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
|
# 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 ledger. Untagged spend is deliberately excluded — it belongs to
|
||||||
# cost-per-hire, and folding it into "Unknown" would fabricate a ROI figure.
|
# cost-per-hire, and folding it into "Unknown" would fabricate a ROI figure.
|
||||||
spend_q=select(
|
spend_map=await HiringCosts.sum_by_source_channel(
|
||||||
HiringCosts.source_channel_id,
|
self.session,from_date=from_date,to_date=to_date,
|
||||||
func.coalesce(func.sum(HiringCosts.amount),0.0),
|
department=department,recruiter_id=recruiter_id,
|
||||||
).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()
|
|
||||||
}
|
|
||||||
|
|
||||||
# A channel with tagged spend but zero applications must still get a row:
|
# 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,
|
# 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}
|
present={source_id for source_id,_,_ in rows}
|
||||||
missing=[cid for cid in spend_map if cid not in present]
|
missing=[cid for cid in spend_map if cid not in present]
|
||||||
if missing:
|
if missing:
|
||||||
channels_q=select(SourceChannels.id,SourceChannels.label).where(SourceChannels.id.in_(missing))
|
|
||||||
rows=list(rows)+[
|
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 [
|
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):
|
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))
|
top=max(1,int(top or 5))
|
||||||
recruiters_q=(
|
recruiters=await Users.list_by_role_name(
|
||||||
select(Users)
|
self.session,EnumRoles.RECRUITER.value,user_id=recruiter_id,
|
||||||
.join(Roles,Users.role_id==Roles.id)
|
|
||||||
.where(
|
|
||||||
Roles.role_name==EnumRoles.RECRUITER.value,
|
|
||||||
Users.is_deleted==False, # noqa: E712
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
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=[]
|
rows=[]
|
||||||
for user in recruiters:
|
for user in recruiters:
|
||||||
hires_q=select(func.count()).select_from(Inbox_Messages).where(
|
hires=await Inbox_Messages.count_hires_by_recruiter(
|
||||||
Inbox_Messages.recruiter_id==user.id,
|
self.session,user.id,from_date=from_date,to_date=to_date,department=department,
|
||||||
Inbox_Messages.application_status==Candidate_application_Status.HIRED,
|
|
||||||
)
|
)
|
||||||
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_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id)
|
||||||
open_posts_q=select(func.count()).select_from(JobPosts).where(
|
open_posts=await JobPosts.count_by_current_recruiter(
|
||||||
JobPosts.current_recruiter_id==user.id,
|
self.session,user.id,status=RequisitionStatus.OPEN.value,department=department,
|
||||||
JobPosts.requisition_status=="open",
|
|
||||||
JobPosts.is_deleted==False, # noqa: E712
|
|
||||||
)
|
)
|
||||||
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)
|
open_reqs=max(open_assign,open_posts)
|
||||||
|
completed=await JobPosts.count_by_current_recruiter(
|
||||||
avg_tth=await self._avg_time_to_hire(
|
self.session,user.id,status=RequisitionStatus.COMPLETED.value,
|
||||||
from_date,to_date,department,recruiter_id=str(user.id)
|
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]
|
return rows[:top]
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,64 @@ class Inbox(SQLModel, table=True):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(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
|
@classmethod
|
||||||
async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None):
|
async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None):
|
||||||
if record_id is None:
|
if record_id is None:
|
||||||
|
|
@ -979,6 +1037,117 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return 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):
|
class Inbox_Message_Triage(SQLModel, table=True):
|
||||||
"""One intake verdict per upstream message id — the gate before inbox_messages.
|
"""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())
|
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):
|
class AtsResults(SQLModel, table=True):
|
||||||
__tablename__ = "ats_results"
|
__tablename__ = "ats_results"
|
||||||
|
|
|
||||||
|
|
@ -752,6 +752,39 @@ async def fetch_job_departments(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
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")
|
@router.get("/jobs/fetch")
|
||||||
async def fetch_jobs(
|
async def fetch_jobs(
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
|
|
@ -895,6 +928,7 @@ async def fetch_interview(
|
||||||
from_date:datetime=Query(None),
|
from_date:datetime=Query(None),
|
||||||
to_date:datetime=Query(None),
|
to_date:datetime=Query(None),
|
||||||
status:str=Query(None),
|
status:str=Query(None),
|
||||||
|
recruiter_id:str=Query(None),
|
||||||
top:int=Query(None),
|
top:int=Query(None),
|
||||||
skip:int=Query(0,ge=0),
|
skip:int=Query(0,ge=0),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
|
@ -902,14 +936,14 @@ async def fetch_interview(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Interview(session=session)
|
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(
|
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})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
data=await service.get_interview(
|
data=await service.get_interview(
|
||||||
interview_id=interview_id,inbox_id=inbox_id,
|
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):
|
if isinstance(data,tuple):
|
||||||
data,total=data
|
data,total=data
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ class JobAssignments(SQLModel, table=True):
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_open_reqs_by_users(cls, session: AsyncSession, user_ids):
|
async def count_open_reqs_by_users(cls, session: AsyncSession, user_ids):
|
||||||
"""Open requisitions per user: current assignments joined to open job_posts."""
|
"""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
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
uids = [u for u in (user_ids or []) if u]
|
uids = [u for u in (user_ids or []) if u]
|
||||||
|
|
@ -117,7 +118,7 @@ class JobAssignments(SQLModel, table=True):
|
||||||
.where(
|
.where(
|
||||||
cls.user_id.in_(uids),
|
cls.user_id.in_(uids),
|
||||||
cls.valid_to.is_(None),
|
cls.valid_to.is_(None),
|
||||||
JobPosts.requisition_status == "open",
|
JobPosts.requisition_status == RequisitionStatus.OPEN.value,
|
||||||
JobPosts.is_deleted == False, # noqa: E712
|
JobPosts.is_deleted == False, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(cls.user_id)
|
.group_by(cls.user_id)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from fastapi import HTTPException
|
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.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
@ -790,6 +790,11 @@ class Interviews(SQLModel, table=True):
|
||||||
interview_type: str = Field(default="")
|
interview_type: str = Field(default="")
|
||||||
interview_status: str = Field(default="")
|
interview_status: str = Field(default="")
|
||||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
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)
|
graph_event_id: str | None = Field(default=None)
|
||||||
web_link: str | None = Field(default=None)
|
web_link: str | None = Field(default=None)
|
||||||
inbox: Optional["Inbox"] = Relationship(
|
inbox: Optional["Inbox"] = Relationship(
|
||||||
|
|
@ -830,6 +835,28 @@ class Interviews(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
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
|
@classmethod
|
||||||
async def get_interviews_in_range(
|
async def get_interviews_in_range(
|
||||||
cls,
|
cls,
|
||||||
|
|
@ -838,6 +865,7 @@ class Interviews(SQLModel, table=True):
|
||||||
from_date=None,
|
from_date=None,
|
||||||
to_date=None,
|
to_date=None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
recruiter_id=None,
|
||||||
top: int | None = None,
|
top: int | None = None,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
):
|
):
|
||||||
|
|
@ -848,6 +876,8 @@ class Interviews(SQLModel, table=True):
|
||||||
statement = statement.where(cls.interview_date < to_date)
|
statement = statement.where(cls.interview_date < to_date)
|
||||||
if status:
|
if status:
|
||||||
statement = statement.where(cls.interview_status == 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())
|
count_statement = select(func.count()).select_from(statement.subquery())
|
||||||
total = (await session.execute(count_statement)).scalar_one()
|
total = (await session.execute(count_statement)).scalar_one()
|
||||||
statement = (
|
statement = (
|
||||||
|
|
@ -860,6 +890,41 @@ class Interviews(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all()), total
|
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
|
@classmethod
|
||||||
async def job_titles_by_inbox(cls, session: AsyncSession, inbox_ids) -> dict[int, str]:
|
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.
|
"""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)
|
result = await session.execute(statement)
|
||||||
return result.scalar_one()
|
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):
|
class CandidateHistory(SQLModel, table=True):
|
||||||
"""Append-only audit log for one candidate (users.id), scoped to an application.
|
"""Append-only audit log for one candidate (users.id), scoped to an application.
|
||||||
|
|
|
||||||
|
|
@ -83,13 +83,46 @@ class HiringCosts(SQLModel, table=True):
|
||||||
return await cls.get_by_id(session, row.id)
|
return await cls.get_by_id(session, row.id)
|
||||||
|
|
||||||
@classmethod
|
@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))
|
statement = select(func.coalesce(func.sum(cls.amount), 0.0))
|
||||||
if from_date is not None:
|
if from_date is not None:
|
||||||
statement = statement.where(cls.incurred_at >= from_date)
|
statement = statement.where(cls.incurred_at >= from_date)
|
||||||
if to_date is not None:
|
if to_date is not None:
|
||||||
statement = statement.where(cls.incurred_at < to_date)
|
statement = statement.where(cls.incurred_at < to_date)
|
||||||
|
statement = cls.scoped_to_job(statement, department, recruiter_id)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return float(result.scalar_one() or 0.0)
|
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
|
import users.models as _users_models # noqa: E402, F401
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class Interview:
|
||||||
async def _serialize(self,row):
|
async def _serialize(self,row):
|
||||||
return serialize_interview(row,job_title=await self._job_title_for(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:
|
if interview_id:
|
||||||
row=await Interviews.get_interview_by_id(self.session,interview_id)
|
row=await Interviews.get_interview_by_id(self.session,interview_id)
|
||||||
if not row:
|
if not row:
|
||||||
|
|
@ -33,18 +33,19 @@ class Interview:
|
||||||
if inbox_id is not None:
|
if inbox_id is not None:
|
||||||
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
|
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
|
||||||
return [serialize_interview(r) for r in rows]
|
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(
|
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")
|
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(
|
rows,total=await Interviews.get_interviews_in_range(
|
||||||
self.session,
|
self.session,
|
||||||
from_date=from_date,
|
from_date=from_date,
|
||||||
to_date=to_date,
|
to_date=to_date,
|
||||||
status=status,
|
status=status,
|
||||||
|
recruiter_id=recruiter_id,
|
||||||
top=top,
|
top=top,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
)
|
)
|
||||||
|
|
@ -52,6 +53,7 @@ class Interview:
|
||||||
return [serialize_interview(r,job_title=titles.get(r.inbox_id)) for r in rows],total
|
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):
|
async def create_interview(self,payload,current_user=None):
|
||||||
|
from inbox.models import Inbox
|
||||||
fields={
|
fields={
|
||||||
"interview_date":payload.get("interview_date"),
|
"interview_date":payload.get("interview_date"),
|
||||||
"interview_time":payload.get("interview_time"),
|
"interview_time":payload.get("interview_time"),
|
||||||
|
|
@ -59,6 +61,13 @@ class Interview:
|
||||||
"interview_status":payload.get("interview_status") or "",
|
"interview_status":payload.get("interview_status") or "",
|
||||||
"inbox_id":payload.get("inbox_id"),
|
"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}
|
fields={k:v for k,v in fields.items() if v is not None}
|
||||||
row=await Interviews.insert_interview(self.session,fields)
|
row=await Interviews.insert_interview(self.session,fields)
|
||||||
when=row.interview_date or row.interview_time
|
when=row.interview_date or row.interview_time
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
@ -20,8 +20,8 @@ BRAND_DARK = "0B3D2E" # header/banner green, matches the app chrome
|
||||||
BRAND_STRIPE = "EFF7F2" # zebra row tint
|
BRAND_STRIPE = "EFF7F2" # zebra row tint
|
||||||
BORDER_TINT = "CBDCD2"
|
BORDER_TINT = "CBDCD2"
|
||||||
|
|
||||||
STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold"}
|
STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold", "completed": "Completed"}
|
||||||
STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700"}
|
STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700", "completed": "0F6E56"}
|
||||||
|
|
||||||
# (header, column width)
|
# (header, column width)
|
||||||
COLUMNS = [
|
COLUMNS = [
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Optional
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
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
|
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
||||||
from users.models import Users
|
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))
|
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
status: str = Field(default="draft")
|
status: str = Field(default="draft")
|
||||||
buffer_error: str | None = Field(default=None)
|
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).
|
# `status`, which tracks Buffer publishing (draft/scheduled/published/failed).
|
||||||
# server_default is load-bearing: this column arrives as an ALTER on a populated table.
|
# 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"})
|
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)
|
result = await session.execute(statement)
|
||||||
return {uid: int(n or 0) for uid, n in result.all()}
|
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
|
@classmethod
|
||||||
async def insert_job_post(cls, session: AsyncSession, fields: dict):
|
async def insert_job_post(cls, session: AsyncSession, fields: dict):
|
||||||
row = cls(**fields)
|
row = cls(**fields)
|
||||||
session.add(row)
|
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()
|
await session.commit()
|
||||||
return await cls.get_job_post_by_id(session, row.id)
|
return await cls.get_job_post_by_id(session, row.id)
|
||||||
|
|
||||||
|
|
@ -289,23 +381,68 @@ class JobPosts(SQLModel, table=True):
|
||||||
return row
|
return row
|
||||||
|
|
||||||
@classmethod
|
@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)
|
row = await cls.get_job_post_by_id(session, record_id)
|
||||||
if not row or row.is_deleted:
|
if not row or row.is_deleted:
|
||||||
return None
|
return None
|
||||||
previous = row.requisition_status
|
previous = row.requisition_status
|
||||||
|
if previous == status:
|
||||||
|
return row
|
||||||
row.requisition_status = status
|
row.requisition_status = status
|
||||||
if status == "closed":
|
terminal = status in ("closed", "completed")
|
||||||
if previous != "closed" or row.closed_at is None:
|
if terminal:
|
||||||
|
if previous not in ("closed", "completed") or row.closed_at is None:
|
||||||
row.closed_at = _now()
|
row.closed_at = _now()
|
||||||
else:
|
else:
|
||||||
row.closed_at = None
|
row.closed_at = None
|
||||||
row.updated_at = _now()
|
row.updated_at = _now()
|
||||||
session.add(row)
|
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()
|
await session.commit()
|
||||||
return await cls.get_job_post_by_id(session, record_id)
|
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):
|
class JobPostImages(SQLModel, table=True):
|
||||||
"""Cover image of a job post, stored as bytes IN the database.
|
"""Cover image of a job post, stored as bytes IN the database.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def serialize_job_post(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"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,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
"updated_at": row.updated_at.isoformat() if row.updated_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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, model_validator
|
from pydantic import BaseModel, model_validator
|
||||||
from inbox.models import Inbox_Messages
|
from inbox.models import Inbox_Messages
|
||||||
from job.assignment.views import Assignment
|
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 role.models import EnumRoles
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
from job.job_post.plugins import (
|
from job.job_post.plugins import (
|
||||||
|
|
@ -25,7 +26,7 @@ from job.job_post.plugins import (
|
||||||
render_job_post,
|
render_job_post,
|
||||||
resolve_channel,
|
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()
|
load_dotenv()
|
||||||
logger=logging.getLogger("job.job_post")
|
logger=logging.getLogger("job.job_post")
|
||||||
|
|
@ -208,6 +209,20 @@ class JobPost:
|
||||||
async def fetch_departments(self,active_only=False):
|
async def fetch_departments(self,active_only=False):
|
||||||
return await JobPosts.list_departments(self.session,active_only=active_only)
|
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,
|
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):
|
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True):
|
||||||
hm_uid=None
|
hm_uid=None
|
||||||
|
|
@ -352,13 +367,20 @@ class JobPost:
|
||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||||
status=(payload.get("requisition_status") or "").strip()
|
status=(payload.get("requisition_status") or "").strip()
|
||||||
allowed=("open","closed","on_hold")
|
parsed=RequisitionStatus.parse(status)
|
||||||
if status not in allowed:
|
if parsed is None:
|
||||||
raise HTTPException(status_code=422,detail=f"requisition_status must be one of {', '.join(allowed)}")
|
raise HTTPException(
|
||||||
row=await JobPosts.set_requisition_status(self.session,job_post_id,status)
|
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:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Job post not found")
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
if status=="closed":
|
if status==RequisitionStatus.CLOSED.value:
|
||||||
try:
|
try:
|
||||||
from notifications.models import Notifications
|
from notifications.models import Notifications
|
||||||
raw=row.current_recruiter_id or (current_user.get("id") if current_user else None)
|
raw=row.current_recruiter_id or (current_user.get("id") if current_user else None)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -115,6 +115,38 @@ class Offers(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalar_one()
|
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):
|
class OfferStatusHistory(SQLModel, table=True):
|
||||||
__tablename__ = "offer_status_history"
|
__tablename__ = "offer_status_history"
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,7 @@ async def _build_recruiter_performance(session, f):
|
||||||
row["avg_time_to_hire"] = _round(row.get("avg_time_to_hire"))
|
row["avg_time_to_hire"] = _round(row.get("avg_time_to_hire"))
|
||||||
columns = [
|
columns = [
|
||||||
{"key": "name", "label": "Recruiter"},
|
{"key": "name", "label": "Recruiter"},
|
||||||
|
{"key": "completed", "label": "Completed Requisitions"},
|
||||||
{"key": "hires", "label": "Hires"},
|
{"key": "hires", "label": "Hires"},
|
||||||
{"key": "open_reqs", "label": "Open Requisitions"},
|
{"key": "open_reqs", "label": "Open Requisitions"},
|
||||||
{"key": "avg_time_to_hire", "label": "Avg Time to Hire (days)"},
|
{"key": "avg_time_to_hire", "label": "Avg Time to Hire (days)"},
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,23 @@ class Users(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalars().all()
|
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
|
@classmethod
|
||||||
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
||||||
"""Resolve {user_id: name} in a single query.
|
"""Resolve {user_id: name} in a single query.
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,13 @@ export const INTERVIEW_TYPES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Range read. `top` is always sent so the route takes the range branch. */
|
/** 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', {
|
return request('/interview/fetch', {
|
||||||
params: {
|
params: {
|
||||||
from_date: fromDate,
|
from_date: fromDate,
|
||||||
to_date: toDate,
|
to_date: toDate,
|
||||||
status,
|
status,
|
||||||
|
recruiter_id: recruiterId,
|
||||||
top,
|
top,
|
||||||
skip,
|
skip,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,22 @@ export function list({ search, department, requisitionStatus, employmentType,
|
||||||
}
|
}
|
||||||
|
|
||||||
/* requisition_status is the HIRING lifecycle. The row's separate `status` field is
|
/* requisition_status is the HIRING lifecycle. The row's separate `status` field is
|
||||||
the Buffer publishing lifecycle — never map the two onto one badge. */
|
the Buffer publishing lifecycle — never map the two onto one badge. Fallback
|
||||||
const REQ_STATUS_LABEL = { open: 'Open', closed: 'Closed', on_hold: 'On Hold' }
|
matches GET /jobs/requisition-statuses/fetch so the dropdown still works if
|
||||||
export const JOB_STATUSES = Object.values(REQ_STATUS_LABEL)
|
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) {
|
function experienceLabel(min, max) {
|
||||||
if (min == null && max == null) return null
|
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
|
* Styled .xlsx download of the requisition list — GET /jobs/export
|
||||||
* (jobs.export). Same filters as list(); `status` takes the UI label.
|
* (jobs.export). Same filters as list(); `status` takes the UI label.
|
||||||
|
|
@ -121,3 +132,8 @@ export function setStatus(jobPostId, status) {
|
||||||
body: { requisition_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 } })
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ export const qk = {
|
||||||
jobs: {
|
jobs: {
|
||||||
all: () => ['jobs'],
|
all: () => ['jobs'],
|
||||||
list: (p = {}) => ['jobs', 'list', p],
|
list: (p = {}) => ['jobs', 'list', p],
|
||||||
|
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
||||||
|
statusHistory: (id) => ['jobs', 'status-history', id],
|
||||||
},
|
},
|
||||||
talent: {
|
talent: {
|
||||||
all: () => ['talent'],
|
all: () => ['talent'],
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import * as jobPostsApi from '../api/jobPosts'
|
||||||
import * as assignmentsApi from '../api/assignments'
|
import * as assignmentsApi from '../api/assignments'
|
||||||
import * as tasksApi from '../api/tasks'
|
import * as tasksApi from '../api/tasks'
|
||||||
import * as usersApi from '../api/users'
|
import * as usersApi from '../api/users'
|
||||||
import { JOB_STATUSES } from '../api/jobs'
|
|
||||||
import { empTypes, fmtShort } from '../data/seed'
|
import { empTypes, fmtShort } from '../data/seed'
|
||||||
|
|
||||||
// Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list).
|
// 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 jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
|
||||||
const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data])
|
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 [q, setQ] = useState('')
|
||||||
const [dept, setDept] = useState('')
|
const [dept, setDept] = useState('')
|
||||||
|
|
@ -92,12 +103,19 @@ export default function Jobs() {
|
||||||
const canDelete = can('jobs.delete')
|
const canDelete = can('jobs.delete')
|
||||||
|
|
||||||
// Deep-link intents from global search, the dashboard and the manager portal.
|
// 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(() => {
|
useEffect(() => {
|
||||||
const st = location.state
|
const st = location.state
|
||||||
if (!st) return
|
if (!st?.openCreate && !st?.openJob) return
|
||||||
if (st.openCreate) setCreating(true)
|
if (st.openCreate) setCreating(true)
|
||||||
if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null)
|
if (st.openJob) {
|
||||||
}, [location.state, jobs])
|
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(() => {
|
useEffect(() => {
|
||||||
if (!viewing) return
|
if (!viewing) return
|
||||||
|
|
@ -239,17 +257,17 @@ export default function Jobs() {
|
||||||
key: '_a', label: 'Actions', align: 'right',
|
key: '_a', label: 'Actions', align: 'right',
|
||||||
render: (j) => (
|
render: (j) => (
|
||||||
<div className="row-actions">
|
<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 && (
|
{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
|
<button
|
||||||
className="act-btn"
|
className="act-btn"
|
||||||
data-tip="Reopen"
|
data-tip="Reopen"
|
||||||
aria-label="Reopen job"
|
aria-label="Reopen job"
|
||||||
disabled={setJobStatus.isPending}
|
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>
|
><Icon name="refresh" /></button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|
@ -257,14 +275,15 @@ export default function Jobs() {
|
||||||
data-tip="Close job"
|
data-tip="Close job"
|
||||||
aria-label="Close job"
|
aria-label="Close job"
|
||||||
disabled={setJobStatus.isPending}
|
disabled={setJobStatus.isPending}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
if (window.confirm(`Close “${j.title}”? It stays on the board and can be reopened later.`)) {
|
if (window.confirm(`Close “${j.title}”? It stays on the board and can be reopened later.`)) {
|
||||||
setJobStatus.mutate({ id: j.id, status: 'Closed' })
|
setJobStatus.mutate({ id: j.id, status: 'Closed' })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
><Icon name="x-circle" /></button>
|
><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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -314,7 +333,7 @@ export default function Jobs() {
|
||||||
</select>
|
</select>
|
||||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
<option value="">All Status</option>
|
<option value="">All Status</option>
|
||||||
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
|
{statusLabels.map((s) => <option key={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
|
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
|
|
@ -327,6 +346,7 @@ export default function Jobs() {
|
||||||
rows={rows}
|
rows={rows}
|
||||||
pageSize={8}
|
pageSize={8}
|
||||||
empty="No requisitions match these filters."
|
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 } }) }}
|
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
|
||||||
onEdit={() => { setEditing(viewing); setViewing(null) }}
|
onEdit={() => { setEditing(viewing); setViewing(null) }}
|
||||||
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
|
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
|
||||||
|
statusLabels={statusLabels}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id)
|
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 MAX_IMAGE_MB = 5
|
||||||
const ROLE_LABEL = { primary_recruiter: 'Recruiter', hiring_manager: 'Hiring manager' }
|
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.
|
* 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).
|
* Not free-text — the value is always an option id (or '' when allowEmpty).
|
||||||
|
|
@ -1047,28 +1075,82 @@ function JobOwnership({ job, canEdit }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssignmentHistory({ historyQuery }) {
|
function JobHistory({ historyQuery, statusQuery }) {
|
||||||
const history = historyQuery.data ?? []
|
const assignments = historyQuery.data ?? []
|
||||||
|
const statusRows = statusQuery.data ?? []
|
||||||
|
|
||||||
if (historyQuery.isError) {
|
if (historyQuery.isError && statusQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<p className="text-muted text-sm">
|
<p className="text-muted text-sm">
|
||||||
{friendlyAuthError(historyQuery.error, 'History did not load.')}
|
{friendlyAuthError(historyQuery.error, 'History did not load.')}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (historyQuery.isPending) {
|
if ((historyQuery.isPending && !historyQuery.data) || (statusQuery.isPending && !statusQuery.data)) {
|
||||||
return <p className="text-muted text-sm">Loading…</p>
|
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 (
|
return (
|
||||||
<div className="list-tight">
|
<div className="list-tight">
|
||||||
{history.map((row) => (
|
{events.map((ev) => (
|
||||||
<div className="list-row" key={row.id}>
|
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-main">
|
||||||
<div className="lr-title">{row.name || 'Unknown'}</div>
|
<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">
|
<div className="lr-sub">
|
||||||
{[
|
{[
|
||||||
ROLE_LABEL[row.role] || row.role?.replace(/_/g, ' '),
|
ROLE_LABEL[row.role] || row.role?.replace(/_/g, ' '),
|
||||||
|
|
@ -1080,8 +1162,6 @@ function AssignmentHistory({ historyQuery }) {
|
||||||
</div>
|
</div>
|
||||||
{!row.validTo && <Badge className="b-green">Current</Badge>}
|
{!row.validTo && <Badge className="b-green">Current</Badge>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1108,6 +1188,7 @@ function JobCover({ jobId }) {
|
||||||
|
|
||||||
function JobDetail({
|
function JobDetail({
|
||||||
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
||||||
|
statusLabels = jobsApi.JOB_STATUSES,
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState('details')
|
const [tab, setTab] = useState('details')
|
||||||
const historyQuery = useQuery({
|
const historyQuery = useQuery({
|
||||||
|
|
@ -1120,6 +1201,16 @@ function JobDetail({
|
||||||
enabled: Boolean(j.id),
|
enabled: Boolean(j.id),
|
||||||
retry: false,
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|
@ -1160,7 +1251,7 @@ function JobDetail({
|
||||||
disabled={statusBusy}
|
disabled={statusBusy}
|
||||||
onChange={(e) => onStatus(e.target.value)}
|
onChange={(e) => onStatus(e.target.value)}
|
||||||
>
|
>
|
||||||
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
|
{statusLabels.map((s) => <option key={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<Badge>{j.status}</Badge>
|
<Badge>{j.status}</Badge>
|
||||||
|
|
@ -1173,7 +1264,7 @@ function JobDetail({
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'details', label: 'Details' },
|
{ 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>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@
|
||||||
is the worklist the prototype filed under Recruiter Hub. Completing a row
|
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
|
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.
|
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'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
@ -143,16 +148,18 @@ export default function RecruiterHub() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const heatQuery = useQuery({
|
const heatQuery = useQuery({
|
||||||
queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS }),
|
queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS, recruiterId: activeId }),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await interviewsApi.listRange({
|
const res = await interviewsApi.listRange({
|
||||||
fromDate: heatFrom.toISOString(),
|
fromDate: heatFrom.toISOString(),
|
||||||
toDate: new Date().toISOString(),
|
toDate: new Date().toISOString(),
|
||||||
|
recruiterId: activeId,
|
||||||
top: 500,
|
top: 500,
|
||||||
})
|
})
|
||||||
const rows = Array.isArray(res?.data) ? res.data : []
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
return rows.map(interviewsApi.toInterviewView)
|
return rows.map(interviewsApi.toInterviewView)
|
||||||
},
|
},
|
||||||
|
enabled: Boolean(activeId),
|
||||||
})
|
})
|
||||||
|
|
||||||
const heatmap = useMemo(() => {
|
const heatmap = useMemo(() => {
|
||||||
|
|
@ -191,7 +198,7 @@ export default function RecruiterHub() {
|
||||||
}, [funnelQuery.data])
|
}, [funnelQuery.data])
|
||||||
|
|
||||||
const board = useMemo(
|
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],
|
[recruiters],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -269,6 +276,8 @@ export default function RecruiterHub() {
|
||||||
<div style={{ opacity: 0.85 }}>
|
<div style={{ opacity: 0.85 }}>
|
||||||
{selected.open_reqs ?? 0} open req{(selected.open_reqs ?? 0) === 1 ? '' : 's'}
|
{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'}
|
{selected.hires ?? 0} hire{(selected.hires ?? 0) === 1 ? '' : 's'}
|
||||||
{canViewTasks && !tasksLoading ? (
|
{canViewTasks && !tasksLoading ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -364,7 +373,7 @@ export default function RecruiterHub() {
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>Interview Load</h3>
|
<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>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
|
@ -403,7 +412,7 @@ export default function RecruiterHub() {
|
||||||
More
|
More
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted" style={{ marginTop: 10, fontSize: 12 }}>
|
<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 recruiter’s jobs.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -414,12 +423,12 @@ export default function RecruiterHub() {
|
||||||
<div className="grid g-2">
|
<div className="grid g-2">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-head">
|
<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>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{board.length === 0 ? (
|
{board.length === 0 ? (
|
||||||
<EmptyState icon="users" title="No hires recorded yet">
|
<EmptyState icon="users" title="No completed requisitions yet">
|
||||||
The board fills in as applications reach the hired stage.
|
Mark a job Completed when hiring finishes to rank recruiters here.
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
board.map((rec, i) => {
|
board.map((rec, i) => {
|
||||||
|
|
@ -445,8 +454,8 @@ export default function RecruiterHub() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lr-right">
|
<div className="lr-right">
|
||||||
<div className="fw-600">{rec.hires ?? 0}</div>
|
<div className="fw-600">{rec.completed ?? 0}</div>
|
||||||
<div className="lr-sub">hires</div>
|
<div className="lr-sub">completed</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,10 @@ export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE,
|
||||||
key={row.id ?? i}
|
key={row.id ?? i}
|
||||||
className={onRowClick ? 'row-click' : undefined}
|
className={onRowClick ? 'row-click' : undefined}
|
||||||
tabIndex={onRowClick ? 0 : 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) => {
|
onKeyDown={onRowClick ? (e) => {
|
||||||
if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row)
|
if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row)
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue