Merge pull request 'Connect_Talent_x_Recruiter' (#34) from Connect_Talent_x_Recruiter into main
Deploy to S3 / deploy (push) Successful in 33s
Details
Deploy to S3 / deploy (push) Successful in 33s
Details
Reviewed-on: #34pull/35/head
commit
cd2dd5a5c0
|
|
@ -515,7 +515,7 @@ All require `analytics.view`. Common query params: `from_date`, `to_date`, `depa
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/analytics/kpis/fetch` | The KPI cards, each with a prior-period comparison |
|
| GET | `/analytics/kpis/fetch` | The KPI cards, each with a prior-period comparison |
|
||||||
| GET | `/analytics/hiring-trend/fetch?months=7` | Applications vs hires by month |
|
| GET | `/analytics/hiring-trend/fetch?months=7` | Applications vs hires by month |
|
||||||
| GET | `/analytics/funnel/fetch` | Candidate count per stage |
|
| GET | `/analytics/funnel/fetch` | Candidate count per stage (inbox + manual-upload, same population as the pipeline board) |
|
||||||
| GET | `/analytics/recruiter-performance/fetch?top=5` | Per recruiter: hires, open reqs, avg time-to-hire |
|
| GET | `/analytics/recruiter-performance/fetch?top=5` | Per recruiter: hires, open reqs, avg time-to-hire |
|
||||||
| GET | `/analytics/source-performance/fetch` | Applications per source channel |
|
| GET | `/analytics/source-performance/fetch` | Applications per source channel |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
@ -12,24 +10,16 @@ from analytics.serializers import (
|
||||||
from inbox.enums import Candidate_application_Status
|
from inbox.enums import Candidate_application_Status
|
||||||
from inbox.models import Inbox,Inbox_Messages,SourceChannels
|
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,Manual_UPLOAD_CANDIDATE
|
||||||
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,27 @@ 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(
|
# Same two sources the pipeline board counts: inbox applications on an
|
||||||
Inbox_Messages.application_status,
|
# assigned job, plus manual-upload candidates. Counting only
|
||||||
func.count().label("count"),
|
# inbox_messages left Add Candidate rows (and anyone dragged to
|
||||||
).select_from(Inbox_Messages)
|
# Interview there) invisible on the dashboard doughnut.
|
||||||
if department or recruiter_id:
|
inbox_counts=await Inbox_Messages.counts_by_application_status(
|
||||||
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
self.session,from_date=from_date,to_date=to_date,
|
||||||
if department:
|
department=department,recruiter_id=recruiter_id,
|
||||||
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:
|
manual_counts=await Manual_UPLOAD_CANDIDATE.counts_by_application_status(
|
||||||
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id)
|
self.session,from_date=from_date,to_date=to_date,
|
||||||
if from_date is not None:
|
department=department,recruiter_id=recruiter_id,
|
||||||
statement=statement.where(Inbox.created_at>=from_date)
|
)
|
||||||
if to_date is not None:
|
counts={stage.value:0 for stage in Candidate_application_Status}
|
||||||
statement=statement.where(Inbox.created_at<to_date)
|
pending=Candidate_application_Status.PENDING.value
|
||||||
statement=statement.group_by(Inbox_Messages.application_status)
|
for src in (inbox_counts,manual_counts):
|
||||||
result=await self.session.execute(statement)
|
for key,n in src.items():
|
||||||
counts={str(row[0].value if hasattr(row[0],"value") else row[0]): int(row[1] or 0) for row in result.all()}
|
n=int(n or 0)
|
||||||
|
if key in counts:
|
||||||
|
counts[key]+=n
|
||||||
|
else:
|
||||||
|
counts[pending]+=n
|
||||||
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 +240,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 +267,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 +286,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 +300,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]
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@ from datetime import timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from assessments.models import Assessments, _now
|
from assessments.models import Assessments, _now
|
||||||
from assessments.serializers import serialize_assessment
|
from assessments.serializers import serialize_assessment
|
||||||
|
|
@ -82,12 +80,7 @@ class Assessment:
|
||||||
|
|
||||||
inbox_by_id = {}
|
inbox_by_id = {}
|
||||||
if inbox_ids:
|
if inbox_ids:
|
||||||
result = await self.session.execute(
|
inbox_by_id = {row.id: row for row in await Inbox.get_by_ids(self.session, inbox_ids)}
|
||||||
select(Inbox)
|
|
||||||
.options(selectinload(Inbox.messages), selectinload(Inbox.user))
|
|
||||||
.where(Inbox.id.in_(inbox_ids))
|
|
||||||
)
|
|
||||||
inbox_by_id = {row.id: row for row in result.scalars().all()}
|
|
||||||
for row in inbox_by_id.values():
|
for row in inbox_by_id.values():
|
||||||
msg = row.messages
|
msg = row.messages
|
||||||
if msg is not None and msg.assigned_job_post_id:
|
if msg is not None and msg.assigned_job_post_id:
|
||||||
|
|
@ -95,10 +88,9 @@ class Assessment:
|
||||||
|
|
||||||
manual_by_id = {}
|
manual_by_id = {}
|
||||||
if manual_ids:
|
if manual_ids:
|
||||||
result = await self.session.execute(
|
manual_by_id = {
|
||||||
select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids))
|
row.id: row for row in await Manual_UPLOAD_CANDIDATE.get_by_ids(self.session, manual_ids)
|
||||||
)
|
}
|
||||||
manual_by_id = {row.id: row for row in result.scalars().all()}
|
|
||||||
for row in manual_by_id.values():
|
for row in manual_by_id.values():
|
||||||
if row.job_post_id:
|
if row.job_post_id:
|
||||||
job_ids.append(row.job_post_id)
|
job_ids.append(row.job_post_id)
|
||||||
|
|
@ -106,8 +98,9 @@ class Assessment:
|
||||||
jobs_by_id = {}
|
jobs_by_id = {}
|
||||||
uids = [j for j in set(job_ids) if j]
|
uids = [j for j in set(job_ids) if j]
|
||||||
if uids:
|
if uids:
|
||||||
result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids)))
|
jobs_by_id = {
|
||||||
jobs_by_id = {row.id: row for row in result.scalars().all()}
|
row.id: row for row in await JobPosts.get_by_ids(self.session, uids, active_only=False)
|
||||||
|
}
|
||||||
return inbox_by_id, manual_by_id, jobs_by_id
|
return inbox_by_id, manual_by_id, jobs_by_id
|
||||||
|
|
||||||
def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id):
|
def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id):
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ import uuid
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from candidate_forms.models import CandidateForms, _now
|
from candidate_forms.models import CandidateForms, _now
|
||||||
from candidate_forms.plugins import (
|
from candidate_forms.plugins import (
|
||||||
|
|
@ -138,12 +136,7 @@ class CandidateForm:
|
||||||
|
|
||||||
inbox_by_id = {}
|
inbox_by_id = {}
|
||||||
if inbox_ids:
|
if inbox_ids:
|
||||||
result = await self.session.execute(
|
inbox_by_id = {row.id: row for row in await Inbox.get_by_ids(self.session, inbox_ids)}
|
||||||
select(Inbox)
|
|
||||||
.options(selectinload(Inbox.messages), selectinload(Inbox.user))
|
|
||||||
.where(Inbox.id.in_(inbox_ids))
|
|
||||||
)
|
|
||||||
inbox_by_id = {row.id: row for row in result.scalars().all()}
|
|
||||||
for row in inbox_by_id.values():
|
for row in inbox_by_id.values():
|
||||||
msg = row.messages
|
msg = row.messages
|
||||||
if msg is not None and msg.assigned_job_post_id:
|
if msg is not None and msg.assigned_job_post_id:
|
||||||
|
|
@ -151,10 +144,9 @@ class CandidateForm:
|
||||||
|
|
||||||
manual_by_id = {}
|
manual_by_id = {}
|
||||||
if manual_ids:
|
if manual_ids:
|
||||||
result = await self.session.execute(
|
manual_by_id = {
|
||||||
select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids))
|
row.id: row for row in await Manual_UPLOAD_CANDIDATE.get_by_ids(self.session, manual_ids)
|
||||||
)
|
}
|
||||||
manual_by_id = {row.id: row for row in result.scalars().all()}
|
|
||||||
for row in manual_by_id.values():
|
for row in manual_by_id.values():
|
||||||
if row.job_post_id:
|
if row.job_post_id:
|
||||||
job_ids.append(row.job_post_id)
|
job_ids.append(row.job_post_id)
|
||||||
|
|
@ -162,17 +154,13 @@ class CandidateForm:
|
||||||
jobs_by_id = {}
|
jobs_by_id = {}
|
||||||
uids = [j for j in set(job_ids) if j]
|
uids = [j for j in set(job_ids) if j]
|
||||||
if uids:
|
if uids:
|
||||||
result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids)))
|
jobs_by_id = {
|
||||||
jobs_by_id = {row.id: row for row in result.scalars().all()}
|
row.id: row for row in await JobPosts.get_by_ids(self.session, uids, active_only=False)
|
||||||
|
}
|
||||||
|
|
||||||
user_ids = {r.interviewer_id for r in rows if r.interviewer_id}
|
user_ids = {r.interviewer_id for r in rows if r.interviewer_id}
|
||||||
user_ids |= {r.created_by for r in rows if r.created_by}
|
user_ids |= {r.created_by for r in rows if r.created_by}
|
||||||
users_by_id = {}
|
users_by_id = await Users.names_by_ids(self.session, user_ids)
|
||||||
if user_ids:
|
|
||||||
result = await self.session.execute(
|
|
||||||
select(Users.id, Users.name).where(Users.id.in_(user_ids))
|
|
||||||
)
|
|
||||||
users_by_id = {uid: name for uid, name in result.all()}
|
|
||||||
return inbox_by_id, manual_by_id, jobs_by_id, users_by_id
|
return inbox_by_id, manual_by_id, jobs_by_id, users_by_id
|
||||||
|
|
||||||
def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id):
|
def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id):
|
||||||
|
|
@ -210,8 +198,8 @@ class CandidateForm:
|
||||||
row,
|
row,
|
||||||
candidate_name=name,
|
candidate_name=name,
|
||||||
job_title=title,
|
job_title=title,
|
||||||
interviewer_name=users_by_id.get(row.interviewer_id),
|
interviewer_name=users_by_id.get(str(row.interviewer_id)) if row.interviewer_id else None,
|
||||||
created_by_name=users_by_id.get(row.created_by),
|
created_by_name=users_by_id.get(str(row.created_by)) if row.created_by else None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ def clamp_in_resume(key,sentinel):
|
||||||
|
|
||||||
|
|
||||||
def _clean_linkedin(value,resume_text):
|
def _clean_linkedin(value,resume_text):
|
||||||
|
"""Keep a LinkedIn URL only when the CV evidences it. Sentinel / invented → None."""
|
||||||
url=(value or "").strip()
|
url=(value or "").strip()
|
||||||
if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"):
|
if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"):
|
||||||
return None
|
return None
|
||||||
|
|
@ -70,7 +71,19 @@ def _clean_linkedin(value,resume_text):
|
||||||
return None
|
return None
|
||||||
if not lowered.startswith("http://") and not lowered.startswith("https://"):
|
if not lowered.startswith("http://") and not lowered.startswith("https://"):
|
||||||
url="https://"+url.lstrip("/")
|
url="https://"+url.lstrip("/")
|
||||||
|
text=(resume_text or "").strip()
|
||||||
|
if not text:
|
||||||
return url
|
return url
|
||||||
|
from linkedin_utils import slug_from_url,slugs_from_text
|
||||||
|
agent_slug=slug_from_url(url)
|
||||||
|
if agent_slug:
|
||||||
|
return url if agent_slug in slugs_from_text(text) else None
|
||||||
|
if "lnkd.in" in lowered:
|
||||||
|
from linkedin_utils import profile_url_from_text
|
||||||
|
evidenced=profile_url_from_text(text)
|
||||||
|
if evidenced and "lnkd.in" in evidenced.lower():
|
||||||
|
return evidenced
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _clean_phone(value,resume_text):
|
def _clean_phone(value,resume_text):
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ linkedin_url (its own key — extract this separately from the other fields):
|
||||||
- Copy the full slug. Never drop a trailing path segment.
|
- Copy the full slug. Never drop a trailing path segment.
|
||||||
- Do not return a company page (linkedin.com/company/...), a search URL, or a URL that is not LinkedIn.
|
- Do not return a company page (linkedin.com/company/...), a search URL, or a URL that is not LinkedIn.
|
||||||
- Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN}
|
- Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN}
|
||||||
|
- Never guess a slug or construct linkedin.com/in/<name> from the candidate's name. The stored value will be null when this sentinel is returned.
|
||||||
|
|
||||||
phone (its own key — extract this separately; copy EVERY digit):
|
phone (its own key — extract this separately; copy EVERY digit):
|
||||||
- Return the candidate's own mobile / phone exactly as written, including country code when present.
|
- Return the candidate's own mobile / phone exactly as written, including country code when present.
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,7 @@ async def set_form_processing_state(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=SheetFormData(session=session)
|
service=SheetFormData(session=session)
|
||||||
data=await service.set_processing_state(record_id,payload.processing_state)
|
data=await service.set_processing_state(record_id,payload.processing_state,current_user)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,13 @@ class SheetImportRun(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_latest(cls, session: AsyncSession):
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls).order_by(cls.created_at.desc()).limit(1)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
|
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
|
||||||
row = cls(**fields)
|
row = cls(**fields)
|
||||||
|
|
|
||||||
|
|
@ -319,11 +319,7 @@ class SheetImport(SheetRead):
|
||||||
row=await SheetImportRun.get_active(session)
|
row=await SheetImportRun.get_active(session)
|
||||||
if row:
|
if row:
|
||||||
return serialize_import_run(row)
|
return serialize_import_run(row)
|
||||||
from sqlmodel import select
|
row=await SheetImportRun.get_latest(session)
|
||||||
result=await session.execute(
|
|
||||||
select(SheetImportRun).order_by(SheetImportRun.created_at.desc()).limit(1)
|
|
||||||
)
|
|
||||||
row=result.scalars().first()
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="No import runs yet")
|
raise HTTPException(status_code=404,detail="No import runs yet")
|
||||||
return serialize_import_run(row)
|
return serialize_import_run(row)
|
||||||
|
|
@ -411,7 +407,7 @@ class SheetFormData(Sheet):
|
||||||
await self._promote_to_application(updated)
|
await self._promote_to_application(updated)
|
||||||
return await self.get_form_data_by_id(record_id)
|
return await self.get_form_data_by_id(record_id)
|
||||||
|
|
||||||
async def set_processing_state(self,record_id,processing_state):
|
async def set_processing_state(self,record_id,processing_state,current_user=None):
|
||||||
allowed=("unread","imported","processed","rejected")
|
allowed=("unread","imported","processed","rejected")
|
||||||
if processing_state not in allowed:
|
if processing_state not in allowed:
|
||||||
raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}")
|
raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}")
|
||||||
|
|
@ -423,7 +419,18 @@ class SheetFormData(Sheet):
|
||||||
if processing_state=="processed":
|
if processing_state=="processed":
|
||||||
if not row.job_post_id:
|
if not row.job_post_id:
|
||||||
raise HTTPException(status_code=422,detail="Assign a job post before moving to shortlist")
|
raise HTTPException(status_code=422,detail="Assign a job post before moving to shortlist")
|
||||||
await self._promote_to_application(row)
|
promoted=await self._promote_to_application(row)
|
||||||
|
status=(getattr(promoted,"status",None) or "").strip()
|
||||||
|
if promoted is not None and status in ("","CLOSED","PROCESS","BANKED","REJECTED"):
|
||||||
|
from job.pipeline.views import Pipeline
|
||||||
|
try:
|
||||||
|
await Pipeline(session).change_stage(
|
||||||
|
"PENDING",current_user,manual_upload_id=promoted.id,
|
||||||
|
change_reason="Moved to shortlist from sheet forms",
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code!=400:
|
||||||
|
raise
|
||||||
updated=await FormData.set_processing_state(session,record_id,processing_state)
|
updated=await FormData.set_processing_state(session,record_id,processing_state)
|
||||||
if not updated:
|
if not updated:
|
||||||
raise HTTPException(status_code=404,detail="Form data not found")
|
raise HTTPException(status_code=404,detail="Form data not found")
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class ReadAllBody(BaseModel):
|
||||||
application_status: Candidate_application_Status = Candidate_application_Status.CLOSED
|
application_status: Candidate_application_Status = Candidate_application_Status.CLOSED
|
||||||
assigned: bool | None = None
|
assigned: bool | None = None
|
||||||
is_duplicate: bool | None = None
|
is_duplicate: bool | None = None
|
||||||
|
processing_state: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TriageOverrideBody(BaseModel):
|
class TriageOverrideBody(BaseModel):
|
||||||
|
|
@ -251,6 +252,7 @@ async def mark_all_inbox_read(
|
||||||
application_status=payload.application_status,
|
application_status=payload.application_status,
|
||||||
assigned=payload.assigned,
|
assigned=payload.assigned,
|
||||||
is_duplicate=payload.is_duplicate,
|
is_duplicate=payload.is_duplicate,
|
||||||
|
processing_state=payload.processing_state,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
|
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -283,6 +285,7 @@ async def get_all_applications(
|
||||||
assigned: bool | None = Query(default=None),
|
assigned: bool | None = Query(default=None),
|
||||||
is_duplicate: bool | None = Query(default=None),
|
is_duplicate: bool | None = Query(default=None),
|
||||||
no_suggestions: bool | None = Query(default=None),
|
no_suggestions: bool | None = Query(default=None),
|
||||||
|
processing_state: str | None = Query(default=None),
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
|
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
|
||||||
top: int | None = Query(None, ge=1, le=500),
|
top: int | None = Query(None, ge=1, le=500),
|
||||||
|
|
@ -293,20 +296,20 @@ async def get_all_applications(
|
||||||
try:
|
try:
|
||||||
service=Email(session=session)
|
service=Email(session=session)
|
||||||
|
|
||||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED):
|
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
||||||
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions)
|
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
||||||
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions)
|
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
if isread==False:
|
if isread==False:
|
||||||
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions)
|
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
||||||
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions)
|
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
if record_id:
|
if record_id:
|
||||||
item=await service.get_application_by_id(record_id)
|
item=await service.get_application_by_id(record_id)
|
||||||
return JSONResponse(content={"data":item,"total":1,"status_code":200})
|
return JSONResponse(content={"data":item,"total":1,"status_code":200})
|
||||||
|
|
||||||
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
|
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
||||||
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
|
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -392,7 +395,7 @@ async def set_processing_state(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Email(session=session)
|
service=Email(session=session)
|
||||||
data=await service.set_processing_state(record_id,payload.processing_state)
|
data=await service.set_processing_state(record_id,payload.processing_state,current_user)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ class Inbox(SQLModel, table=True):
|
||||||
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||||
.outerjoin(AtsResults,cls.ats_id==AtsResults.id)
|
.outerjoin(AtsResults,cls.ats_id==AtsResults.id)
|
||||||
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
||||||
|
.where(Inbox_Messages.attachment==True) # noqa: E712
|
||||||
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
||||||
# Newest-first is the list contract; score is only a tiebreak
|
# Newest-first is the list contract; score is only a tiebreak
|
||||||
# within the same instant. id keeps paging stable.
|
# within the same instant. id keeps paging stable.
|
||||||
|
|
@ -185,6 +186,7 @@ class Inbox(SQLModel, table=True):
|
||||||
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
|
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
|
||||||
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||||
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
||||||
|
.where(Inbox_Messages.attachment==True) # noqa: E712
|
||||||
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
||||||
.group_by(Inbox_Messages.application_status)
|
.group_by(Inbox_Messages.application_status)
|
||||||
)
|
)
|
||||||
|
|
@ -259,6 +261,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:
|
||||||
|
|
@ -270,6 +330,23 @@ class Inbox(SQLModel, table=True):
|
||||||
result=await session.execute(select(cls).where(cls.id==iid))
|
result=await session.execute(select(cls).where(cls.id==iid))
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_ids(cls,session:AsyncSession,ids):
|
||||||
|
keys=[]
|
||||||
|
for raw in ids or []:
|
||||||
|
try:
|
||||||
|
keys.append(int(raw))
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
continue
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
result=await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.options(selectinload(cls.messages),selectinload(cls.user))
|
||||||
|
.where(cls.id.in_(keys))
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None):
|
async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None):
|
||||||
"""Inbox row with `messages` selectin-loaded for stage / application writers."""
|
"""Inbox row with `messages` selectin-loaded for stage / application writers."""
|
||||||
|
|
@ -700,6 +777,7 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
assigned: bool | None=None,
|
assigned: bool | None=None,
|
||||||
is_duplicate: bool | None=None,
|
is_duplicate: bool | None=None,
|
||||||
no_suggestions: bool | None=None,
|
no_suggestions: bool | None=None,
|
||||||
|
processing_state: str | None=None,
|
||||||
):
|
):
|
||||||
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE.
|
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE.
|
||||||
|
|
||||||
|
|
@ -729,11 +807,16 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
func.jsonb_array_length(cls.suggested_job_post_ids) == 0,
|
func.jsonb_array_length(cls.suggested_job_post_ids) == 0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if processing_state:
|
||||||
|
statement = statement.where(cls.processing_state == processing_state)
|
||||||
|
# Inbox / Job Matching only list applications that arrived with a file.
|
||||||
|
# Graph hasAttachments lands on this column; body-only mail stays out.
|
||||||
|
statement = statement.where(cls.attachment == True) # noqa: E712
|
||||||
return statement
|
return statement
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_inbox_messages(
|
async def get_inbox_messages(
|
||||||
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None
|
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None
|
||||||
):
|
):
|
||||||
# Page size is the caller's `top` (Inbox sends 10/25/50/100); `skip` is
|
# Page size is the caller's `top` (Inbox sends 10/25/50/100); `skip` is
|
||||||
# (page-1)*top so page 1 of 25 -> 0..24, page 2 -> 25..49. Newest first
|
# (page-1)*top so page 1 of 25 -> 0..24, page 2 -> 25..49. Newest first
|
||||||
|
|
@ -741,7 +824,7 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
statement = cls._apply_filters(
|
statement = cls._apply_filters(
|
||||||
select(cls).order_by(cls.created_at.desc()),
|
select(cls).order_by(cls.created_at.desc()),
|
||||||
search, isread, application_status, assigned, is_duplicate,
|
search, isread, application_status, assigned, is_duplicate,
|
||||||
no_suggestions,
|
no_suggestions, processing_state,
|
||||||
)
|
)
|
||||||
if skip:
|
if skip:
|
||||||
statement = statement.offset(skip)
|
statement = statement.offset(skip)
|
||||||
|
|
@ -809,11 +892,11 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
return {str(job_id): int(n) for job_id, n in result.all()}
|
return {str(job_id): int(n) for job_id, n in result.all()}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None):
|
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None):
|
||||||
statement = cls._apply_filters(
|
statement = cls._apply_filters(
|
||||||
select(func.count()).select_from(cls),
|
select(func.count()).select_from(cls),
|
||||||
search, isread, application_status, assigned, is_duplicate,
|
search, isread, application_status, assigned, is_duplicate,
|
||||||
no_suggestions,
|
no_suggestions, processing_state,
|
||||||
)
|
)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
@ -917,6 +1000,7 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
application_status: Candidate_application_Status=Candidate_application_Status.CLOSED,
|
application_status: Candidate_application_Status=Candidate_application_Status.CLOSED,
|
||||||
assigned: bool | None=None,
|
assigned: bool | None=None,
|
||||||
is_duplicate: bool | None=None,
|
is_duplicate: bool | None=None,
|
||||||
|
processing_state: str | None=None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Mark every row matching a list filter. Returns rows actually CHANGED.
|
"""Mark every row matching a list filter. Returns rows actually CHANGED.
|
||||||
|
|
||||||
|
|
@ -925,7 +1009,7 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
was already read. It also keeps read_overridden_at off rows nobody decided
|
was already read. It also keeps read_overridden_at off rows nobody decided
|
||||||
anything about, so the Outlook sweep keeps its reach over untouched mail.
|
anything about, so the Outlook sweep keeps its reach over untouched mail.
|
||||||
"""
|
"""
|
||||||
statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate)
|
statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate,processing_state=processing_state)
|
||||||
statement=statement.where(cls.message_read!=bool(read))
|
statement=statement.where(cls.message_read!=bool(read))
|
||||||
result=await session.execute(
|
result=await session.execute(
|
||||||
statement.values(message_read=bool(read),read_overridden_at=_now())
|
statement.values(message_read=bool(read),read_overridden_at=_now())
|
||||||
|
|
@ -939,12 +1023,12 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
func.count().label("all_count"),
|
func.count().label("all_count"),
|
||||||
func.coalesce(func.sum(case((cls.message_read == False, 1), else_=0)), 0).label("unread"), # noqa: E712
|
func.coalesce(func.sum(case((cls.message_read == False, 1), else_=0)), 0).label("unread"), # noqa: E712
|
||||||
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
|
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
|
||||||
func.coalesce(func.sum(case((cls.application_status == Candidate_application_Status.PROCESS, 1), else_=0)), 0).label("processed"),
|
func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"),
|
||||||
func.coalesce(func.sum(case((cls.application_status == Candidate_application_Status.REJECTED, 1), else_=0)), 0).label("rejected"),
|
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
|
||||||
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
|
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
|
||||||
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"),
|
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"),
|
||||||
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"),
|
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"),
|
||||||
)
|
).where(cls.attachment == True) # noqa: E712
|
||||||
row = (await session.execute(statement)).one()
|
row = (await session.execute(statement)).one()
|
||||||
return {
|
return {
|
||||||
"all": int(row.all_count or 0),
|
"all": int(row.all_count or 0),
|
||||||
|
|
@ -963,6 +1047,15 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
row.processing_state = processing_state
|
row.processing_state = processing_state
|
||||||
|
# Pipeline Shortlist reads application_status=PENDING. CLOSED is the
|
||||||
|
# inbox default and maps to Rejected on the board — leaving it unchanged
|
||||||
|
# here is why "Move to Shortlist" never landed in Shortlist.
|
||||||
|
current = row.application_status
|
||||||
|
current_val = current.value if isinstance(current, Candidate_application_Status) else str(current or "")
|
||||||
|
if processing_state == "processed" and current_val in ("", "CLOSED", "PROCESS"):
|
||||||
|
row.application_status = Candidate_application_Status.PENDING
|
||||||
|
elif processing_state == "rejected":
|
||||||
|
row.application_status = Candidate_application_Status.REJECTED
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
|
|
@ -979,6 +1072,145 @@ 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,
|
||||||
|
):
|
||||||
|
"""Current-stage counts for the same inbox population the pipeline board uses.
|
||||||
|
|
||||||
|
Assigned-to-a-job, candidate-role only — Inbox.count_by_status without a
|
||||||
|
job filter. Optional department / recruiter / created_at window sit on
|
||||||
|
top of that; with none of those this matches the board's inbox column.
|
||||||
|
"""
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
statement = (
|
||||||
|
select(cls.application_status, func.count().label("count"))
|
||||||
|
.select_from(Inbox)
|
||||||
|
.join(Users, Inbox.user_id == Users.id)
|
||||||
|
.join(Roles, Users.role_id == Roles.id)
|
||||||
|
.join(cls, Inbox.message_id == cls.id)
|
||||||
|
.join(JobPosts, cls.assigned_job_post_id == JobPosts.id)
|
||||||
|
.where(cls.assigned_job_post_id.is_not(None))
|
||||||
|
.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)
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
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 +1400,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"
|
||||||
|
|
@ -1209,6 +1449,18 @@ class AtsResults(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_ids(cls, session: AsyncSession, ids):
|
||||||
|
keys = []
|
||||||
|
for raw in ids or []:
|
||||||
|
uid = cls._as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
keys.append(uid)
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
result = await session.execute(select(cls).where(cls.id.in_(keys)))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_for_inbox_job(cls, session: AsyncSession, inbox_id, job_post_id):
|
async def get_for_inbox_job(cls, session: AsyncSession, inbox_id, job_post_id):
|
||||||
"""Any score for this application against this job — current or superseded.
|
"""Any score for this application against this job — current or superseded.
|
||||||
|
|
@ -1357,6 +1609,13 @@ class MailboxSyncRun(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_latest(cls, session: AsyncSession):
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls).order_by(cls.created_at.desc()).limit(1)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
|
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
|
||||||
row = cls(**fields)
|
row = cls(**fields)
|
||||||
|
|
|
||||||
|
|
@ -273,13 +273,13 @@ class Email:
|
||||||
item["assigned_job_post"]=None
|
item["assigned_job_post"]=None
|
||||||
return item
|
return item
|
||||||
|
|
||||||
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None):
|
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
|
||||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED):
|
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
||||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
||||||
elif isread==False:
|
elif isread==False:
|
||||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
||||||
else:
|
else:
|
||||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
||||||
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
|
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
|
||||||
return [serialize_application(m,linkedin_url=urls.get(m.id)) for m in messages]
|
return [serialize_application(m,linkedin_url=urls.get(m.id)) for m in messages]
|
||||||
|
|
||||||
|
|
@ -351,11 +351,7 @@ class Email:
|
||||||
if row:
|
if row:
|
||||||
return serialize_mailbox_sync_run(row)
|
return serialize_mailbox_sync_run(row)
|
||||||
# Latest finished run so the UI can still show the last result after refresh.
|
# Latest finished run so the UI can still show the last result after refresh.
|
||||||
from sqlmodel import select
|
row=await MailboxSyncRun.get_latest(self.session)
|
||||||
result=await self.session.execute(
|
|
||||||
select(MailboxSyncRun).order_by(MailboxSyncRun.created_at.desc()).limit(1)
|
|
||||||
)
|
|
||||||
row=result.scalars().first()
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="No sync runs yet")
|
raise HTTPException(status_code=404,detail="No sync runs yet")
|
||||||
return serialize_mailbox_sync_run(row)
|
return serialize_mailbox_sync_run(row)
|
||||||
|
|
@ -431,13 +427,13 @@ class Email:
|
||||||
results.append({"email":email,"sent":False})
|
results.append({"email":email,"sent":False})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None):
|
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
|
||||||
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state:
|
||||||
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
|
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
||||||
elif isread==False:
|
elif isread==False:
|
||||||
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
|
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
||||||
else:
|
else:
|
||||||
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
|
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
||||||
|
|
||||||
async def assign_job_post(self,record_id,job_post_id):
|
async def assign_job_post(self,record_id,job_post_id):
|
||||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
|
|
@ -495,7 +491,7 @@ class Email:
|
||||||
|
|
||||||
async def set_read_all(self,read,search=None,isread:bool=True,
|
async def set_read_all(self,read,search=None,isread:bool=True,
|
||||||
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
|
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
|
||||||
assigned=None,is_duplicate=None):
|
assigned=None,is_duplicate=None,processing_state=None):
|
||||||
"""Mark every row the SAME filter set would have listed.
|
"""Mark every row the SAME filter set would have listed.
|
||||||
|
|
||||||
The filter arguments are the caller's current view, not a free-form query: the
|
The filter arguments are the caller's current view, not a free-form query: the
|
||||||
|
|
@ -505,6 +501,7 @@ class Email:
|
||||||
updated=await Inbox_Messages.set_read_scope(
|
updated=await Inbox_Messages.set_read_scope(
|
||||||
self.session,read,search=search,isread=isread,
|
self.session,read,search=search,isread=isread,
|
||||||
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
|
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
|
||||||
|
processing_state=processing_state,
|
||||||
)
|
)
|
||||||
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s",
|
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s",
|
||||||
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)
|
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)
|
||||||
|
|
@ -531,10 +528,33 @@ class Email:
|
||||||
async def get_counts(self):
|
async def get_counts(self):
|
||||||
return await Inbox_Messages.count_processing(self.session)
|
return await Inbox_Messages.count_processing(self.session)
|
||||||
|
|
||||||
async def set_processing_state(self,record_id,processing_state):
|
async def set_processing_state(self,record_id,processing_state,current_user=None):
|
||||||
allowed=("unread","imported","processed","rejected")
|
allowed=("unread","imported","processed","rejected")
|
||||||
if processing_state not in allowed:
|
if processing_state not in allowed:
|
||||||
raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}")
|
raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}")
|
||||||
|
if processing_state=="processed":
|
||||||
|
existing=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
if not existing.assigned_job_post_id:
|
||||||
|
raise HTTPException(status_code=422,detail="Assign a job post before moving to shortlist")
|
||||||
|
# Record CLOSED/PROCESS → PENDING so the board history matches the card.
|
||||||
|
current=existing.application_status
|
||||||
|
current_val=current.value if isinstance(current,Candidate_application_Status) else str(current or "")
|
||||||
|
if current_val in ("","CLOSED","PROCESS"):
|
||||||
|
link=await Inbox.get_inbox_by_message_id(self.session,existing.id)
|
||||||
|
if link is not None:
|
||||||
|
from job.pipeline.views import Pipeline
|
||||||
|
try:
|
||||||
|
await Pipeline(self.session).change_stage(
|
||||||
|
Candidate_application_Status.PENDING.value,
|
||||||
|
current_user,
|
||||||
|
inbox_id=link.id,
|
||||||
|
change_reason="Moved to shortlist from inbox",
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code!=400:
|
||||||
|
raise
|
||||||
message=await Inbox_Messages.set_processing_state(self.session,record_id,processing_state)
|
message=await Inbox_Messages.set_processing_state(self.session,record_id,processing_state)
|
||||||
if not message:
|
if not message:
|
||||||
raise HTTPException(status_code=404,detail="Message not found")
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query,Response
|
||||||
from fastapi.responses import FileResponse,JSONResponse
|
from fastapi.responses import FileResponse,JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
from job.candidate.views import CandidateScoring,FileRead,CandidateView
|
from job.candidate.views import CandidateScoring,FileRead,CandidateView,parse_linkedin_url_from_cv
|
||||||
from job.interviews.views import Interview
|
from job.interviews.views import Interview
|
||||||
from job.notes.views import Note
|
from job.notes.views import Note
|
||||||
from job.activity.views import ActivityLog
|
from job.activity.views import ActivityLog
|
||||||
|
|
@ -33,6 +33,11 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class MatchingAssign(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
job_post_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class CandidateUpdate(BaseModel):
|
class CandidateUpdate(BaseModel):
|
||||||
favorite: bool | None = None
|
favorite: bool | None = None
|
||||||
rating: float | None = None
|
rating: float | None = None
|
||||||
|
|
@ -133,6 +138,8 @@ class JobUpdate(BaseModel):
|
||||||
experience_min: int | None = None
|
experience_min: int | None = None
|
||||||
experience_max: int | None = None
|
experience_max: int | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
current_recruiter_id: UUID | None = None
|
||||||
|
hiring_manager_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class JobStatusUpdate(BaseModel):
|
class JobStatusUpdate(BaseModel):
|
||||||
|
|
@ -297,6 +304,7 @@ async def cv_bank_upload(
|
||||||
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
|
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
|
||||||
text=parsed.get("text") or ""
|
text=parsed.get("text") or ""
|
||||||
detected,_=extract_candidate_email(text)
|
detected,_=extract_candidate_email(text)
|
||||||
|
parsed_linkedin=await parse_linkedin_url_from_cv(text)
|
||||||
# Basename against both separator styles — a Windows client sends
|
# Basename against both separator styles — a Windows client sends
|
||||||
# C:\Users\x\cv.pdf whose PosixPath name is the whole string.
|
# C:\Users\x\cv.pdf whose PosixPath name is the whole string.
|
||||||
original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf"
|
original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf"
|
||||||
|
|
@ -308,6 +316,7 @@ async def cv_bank_upload(
|
||||||
file_name=original,
|
file_name=original,
|
||||||
created_by=current_user.get("id"),
|
created_by=current_user.get("id"),
|
||||||
pdf_bytes=content,
|
pdf_bytes=content,
|
||||||
|
linkedin_url=parsed_linkedin,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
uploaded=S3().upload_for_record(
|
uploaded=S3().upload_for_record(
|
||||||
|
|
@ -332,6 +341,7 @@ async def cv_bank_upload(
|
||||||
"file_name":row.file_name,
|
"file_name":row.file_name,
|
||||||
"file_path":row.file_path or None,
|
"file_path":row.file_path or None,
|
||||||
"candidate_email":row.candidate_email or None,
|
"candidate_email":row.candidate_email or None,
|
||||||
|
"linkedin_url":row.linkedin_url or None,
|
||||||
"created_at":row.created_at.isoformat() if row.created_at else None,
|
"created_at":row.created_at.isoformat() if row.created_at else None,
|
||||||
},"status_code":200})
|
},"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -359,6 +369,7 @@ async def cv_bank_fetch(
|
||||||
"file_path":(r.file_path or "").strip() or None,
|
"file_path":(r.file_path or "").strip() or None,
|
||||||
"candidate_email":r.candidate_email or None,
|
"candidate_email":r.candidate_email or None,
|
||||||
"candidate_name":r.candidate_name or None,
|
"candidate_name":r.candidate_name or None,
|
||||||
|
"linkedin_url":r.linkedin_url or None,
|
||||||
"created_at":r.created_at.isoformat() if r.created_at else None,
|
"created_at":r.created_at.isoformat() if r.created_at else None,
|
||||||
} for r in rows]
|
} for r in rows]
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
|
|
@ -423,6 +434,61 @@ async def cv_bank_delete(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidate/matching/fetch")
|
||||||
|
async def matching_fetch(
|
||||||
|
top: int = Query(10, ge=1, le=500),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
assigned: bool | None = Query(default=None),
|
||||||
|
search: str | None = Query(default=None),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Job Matching queue: CV Import 'No job' rows (apply_via=cv_bank)."""
|
||||||
|
try:
|
||||||
|
service=CandidateView(session=session)
|
||||||
|
data,total=await service.list_matching(
|
||||||
|
assigned=assigned,search=search,limit=top,offset=skip,
|
||||||
|
)
|
||||||
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidate/matching/fetch_by_id")
|
||||||
|
async def matching_fetch_by_id(
|
||||||
|
id: str = Query(...),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=CandidateView(session=session)
|
||||||
|
data=await service.get_matching(id)
|
||||||
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidate/matching/assign")
|
||||||
|
async def matching_assign(
|
||||||
|
payload: MatchingAssign,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Set job_post_id on a CV-bank row — it then joins like any manual upload."""
|
||||||
|
try:
|
||||||
|
service=CandidateView(session=session)
|
||||||
|
data=await service.assign_matching(payload.id,payload.job_post_id,current_user.get("id"))
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/candidate/inbox-match")
|
@router.post("/candidate/inbox-match")
|
||||||
async def candidate_inbox_match(
|
async def candidate_inbox_match(
|
||||||
inbox_message_id: str = Query(...),
|
inbox_message_id: str = Query(...),
|
||||||
|
|
@ -686,12 +752,46 @@ 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),
|
||||||
department: str | None = Query(None),
|
department: str | None = Query(None),
|
||||||
requisition_status: str | None = Query(None),
|
requisition_status: str | None = Query(None),
|
||||||
employment_type: str | None = Query(None),
|
employment_type: str | None = Query(None),
|
||||||
|
hiring_manager_id: str | None = Query(None),
|
||||||
# le=500 (not 100): the Jobs board loads a full client-side page for facets;
|
# le=500 (not 100): the Jobs board loads a full client-side page for facets;
|
||||||
# a 200 ceiling used to 422 the SPA and render an empty requisition list.
|
# a 200 ceiling used to 422 the SPA and render an empty requisition list.
|
||||||
top: int | None = Query(10, ge=1, le=500),
|
top: int | None = Query(10, ge=1, le=500),
|
||||||
|
|
@ -707,7 +807,8 @@ async def fetch_jobs(
|
||||||
service=JobPost(session=session)
|
service=JobPost(session=session)
|
||||||
data,total=await service.fetch_jobs(
|
data,total=await service.fetch_jobs(
|
||||||
search=search,department=department,requisition_status=requisition_status,
|
search=search,department=department,requisition_status=requisition_status,
|
||||||
employment_type=employment_type,top=top,skip=skip,active_only=active_only,
|
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
|
||||||
|
top=top,skip=skip,active_only=active_only,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -722,6 +823,7 @@ async def export_jobs(
|
||||||
department: str | None = Query(None),
|
department: str | None = Query(None),
|
||||||
requisition_status: str | None = Query(None),
|
requisition_status: str | None = Query(None),
|
||||||
employment_type: str | None = Query(None),
|
employment_type: str | None = Query(None),
|
||||||
|
hiring_manager_id: str | None = Query(None),
|
||||||
active_only: bool = Query(False),
|
active_only: bool = Query(False),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)),
|
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|
@ -731,7 +833,8 @@ async def export_jobs(
|
||||||
service=JobPost(session=session)
|
service=JobPost(session=session)
|
||||||
data,_=await service.fetch_jobs(
|
data,_=await service.fetch_jobs(
|
||||||
search=search,department=department,requisition_status=requisition_status,
|
search=search,department=department,requisition_status=requisition_status,
|
||||||
employment_type=employment_type,top=None,skip=0,active_only=active_only,
|
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
|
||||||
|
top=None,skip=0,active_only=active_only,
|
||||||
)
|
)
|
||||||
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
|
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -825,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)),
|
||||||
|
|
@ -832,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
|
||||||
|
|
@ -1104,12 +1208,16 @@ async def fetch_pipeline_transitions(
|
||||||
@router.get("/job/assignments/fetch")
|
@router.get("/job/assignments/fetch")
|
||||||
async def fetch_job_assignments(
|
async def fetch_job_assignments(
|
||||||
job_post_id:str=Query(...),
|
job_post_id:str=Query(...),
|
||||||
|
current_only:bool=Query(True),
|
||||||
|
assignment_role:str=Query(None),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Assignment(session=session)
|
service=Assignment(session=session)
|
||||||
data=await service.list_job_assignments(job_post_id)
|
data=await service.list_job_assignments(
|
||||||
|
job_post_id,current_only=current_only,assignment_role=assignment_role,
|
||||||
|
)
|
||||||
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -40,17 +40,48 @@ class JobAssignments(SQLModel, table=True):
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def fetch_by_job(cls, session: AsyncSession, job_post_id, *, current_only: bool = True):
|
async def fetch_by_job(
|
||||||
|
cls,
|
||||||
|
session: AsyncSession,
|
||||||
|
job_post_id,
|
||||||
|
*,
|
||||||
|
current_only: bool = True,
|
||||||
|
assignment_role: str | None = None,
|
||||||
|
):
|
||||||
uid = cls._as_uuid(job_post_id)
|
uid = cls._as_uuid(job_post_id)
|
||||||
if uid is None:
|
if uid is None:
|
||||||
return []
|
return []
|
||||||
statement = select(cls).where(cls.job_post_id == uid)
|
statement = select(cls).where(cls.job_post_id == uid)
|
||||||
if current_only:
|
if current_only:
|
||||||
statement = statement.where(cls.valid_to.is_(None))
|
statement = statement.where(cls.valid_to.is_(None))
|
||||||
|
if assignment_role:
|
||||||
|
statement = statement.where(cls.assignment_role == assignment_role)
|
||||||
statement = statement.order_by(cls.valid_from.desc())
|
statement = statement.order_by(cls.valid_from.desc())
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def close_current(cls, session: AsyncSession, job_post_id, assignment_role):
|
||||||
|
"""End every open interval of this role on the job. Returns how many closed."""
|
||||||
|
uid = cls._as_uuid(job_post_id)
|
||||||
|
if uid is None or not assignment_role:
|
||||||
|
return 0
|
||||||
|
statement = select(cls).where(
|
||||||
|
cls.job_post_id == uid,
|
||||||
|
cls.assignment_role == assignment_role,
|
||||||
|
cls.valid_to.is_(None),
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
rows = list(result.scalars().all())
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
now = _now()
|
||||||
|
for row in rows:
|
||||||
|
row.valid_to = now
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def insert_assignment(cls, session: AsyncSession, fields: dict):
|
async def insert_assignment(cls, session: AsyncSession, fields: dict):
|
||||||
row = cls(**fields)
|
row = cls(**fields)
|
||||||
|
|
@ -74,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]
|
||||||
|
|
@ -86,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)
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,34 @@
|
||||||
def serialize_job_assignment(row) -> dict:
|
def serialize_job_assignment(row, names=None) -> dict:
|
||||||
|
names = names or {}
|
||||||
|
user_key = str(row.user_id) if row.user_id else None
|
||||||
|
by_key = str(row.assigned_by) if row.assigned_by else None
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||||
"user_id": str(row.user_id) if row.user_id else None,
|
"user_id": user_key,
|
||||||
|
"user_name": names.get(user_key) if user_key else None,
|
||||||
"assignment_role": row.assignment_role,
|
"assignment_role": row.assignment_role,
|
||||||
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
||||||
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
|
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
|
||||||
"assigned_by": str(row.assigned_by) if row.assigned_by else None,
|
"assigned_by": by_key,
|
||||||
|
"assigned_by_name": names.get(by_key) if by_key else None,
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_application_assignment(row) -> dict:
|
def serialize_application_assignment(row, names=None) -> dict:
|
||||||
|
names = names or {}
|
||||||
|
user_key = str(row.user_id) if row.user_id else None
|
||||||
|
by_key = str(row.assigned_by) if row.assigned_by else None
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"inbox_id": row.inbox_id,
|
"inbox_id": row.inbox_id,
|
||||||
"user_id": str(row.user_id) if row.user_id else None,
|
"user_id": user_key,
|
||||||
|
"user_name": names.get(user_key) if user_key else None,
|
||||||
"assignment_role": row.assignment_role,
|
"assignment_role": row.assignment_role,
|
||||||
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
||||||
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
|
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
|
||||||
"assigned_by": str(row.assigned_by) if row.assigned_by else None,
|
"assigned_by": by_key,
|
||||||
|
"assigned_by_name": names.get(by_key) if by_key else None,
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,58 +3,129 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from job.assignment.models import ApplicationAssignments, JobAssignments
|
from job.assignment.models import ApplicationAssignments, JobAssignments
|
||||||
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
|
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
from role.models import EnumRoles, Roles
|
from role.models import EnumRoles, Roles
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
||||||
|
# job_assignments.assignment_role → the users.role that may hold it.
|
||||||
|
# primary_recruiter is swappable; hiring_manager is the requisition owner.
|
||||||
|
JOB_ASSIGNMENT_ROLES = {
|
||||||
|
"primary_recruiter": EnumRoles.RECRUITER,
|
||||||
|
"hiring_manager": EnumRoles.HIRING_MANAGER,
|
||||||
|
}
|
||||||
|
JOB_OWNER_COLUMN = {
|
||||||
|
"primary_recruiter": "current_recruiter_id",
|
||||||
|
"hiring_manager": "hiring_manager_id",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Assignment:
|
class Assignment:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
self.session=session
|
self.session=session
|
||||||
|
|
||||||
async def _require_recruiter(self,user_id):
|
async def require_role(self,user_id,role_enum,field_name):
|
||||||
role=await Roles.get_role_by_name(self.session,EnumRoles.RECRUITER.value)
|
role=await Roles.get_role_by_name(self.session,role_enum.value)
|
||||||
user=await Users.get_user_by_id(self.session,user_id)
|
user=await Users.get_user_by_id(self.session,user_id)
|
||||||
if not role or not user or user.role_id!=role.id:
|
if not role or not user or user.role_id!=role.id:
|
||||||
raise HTTPException(status_code=422,detail="user_id must be a recruiter")
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"{field_name} must be a {role_enum.value}",
|
||||||
|
)
|
||||||
|
if not user.is_active or user.is_deleted:
|
||||||
|
raise HTTPException(status_code=422,detail=f"{field_name} is not an active user")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def list_job_assignments(self,job_post_id):
|
def _job_role(self,raw):
|
||||||
|
key=(raw or "primary_recruiter").strip()
|
||||||
|
if key=="recruiter":
|
||||||
|
key="primary_recruiter"
|
||||||
|
if key not in JOB_ASSIGNMENT_ROLES:
|
||||||
|
allowed=", ".join(sorted(JOB_ASSIGNMENT_ROLES))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"assignment_role must be one of {allowed}",
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
async def record_job_owner(self,job_post_id,user_id,assignment_role,assigned_by):
|
||||||
|
"""Close the open interval of this role, then open a new one.
|
||||||
|
|
||||||
|
user_id None = unassign (hiring_manager cannot be cleared; callers
|
||||||
|
must not pass None for that role). No-ops when the same person already
|
||||||
|
holds the open interval. Does not touch job_posts columns.
|
||||||
|
"""
|
||||||
|
role=self._job_role(assignment_role)
|
||||||
|
job_uid=JobAssignments._as_uuid(job_post_id)
|
||||||
|
by_uid=JobAssignments._as_uuid(assigned_by)
|
||||||
|
if not job_uid or not by_uid:
|
||||||
|
raise HTTPException(status_code=422,detail="Invalid job_post_id or assigned_by")
|
||||||
|
current=await JobAssignments.fetch_by_job(
|
||||||
|
self.session,job_uid,current_only=True,assignment_role=role,
|
||||||
|
)
|
||||||
|
if user_id is None:
|
||||||
|
if role=="hiring_manager":
|
||||||
|
raise HTTPException(status_code=422,detail="hiring_manager_id is required")
|
||||||
|
await JobAssignments.close_current(self.session,job_uid,role)
|
||||||
|
return None
|
||||||
|
user_uid=JobAssignments._as_uuid(user_id)
|
||||||
|
if not user_uid:
|
||||||
|
raise HTTPException(status_code=422,detail="Invalid user_id")
|
||||||
|
if current and str(current[0].user_id)==str(user_uid):
|
||||||
|
return current[0]
|
||||||
|
await JobAssignments.close_current(self.session,job_uid,role)
|
||||||
|
return await JobAssignments.insert_assignment(self.session,{
|
||||||
|
"job_post_id":job_uid,
|
||||||
|
"user_id":user_uid,
|
||||||
|
"assignment_role":role,
|
||||||
|
"assigned_by":by_uid,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def list_job_assignments(self,job_post_id,current_only=True,assignment_role=None):
|
||||||
if not job_post_id:
|
if not job_post_id:
|
||||||
raise HTTPException(status_code=400,detail="job_post_id is required")
|
raise HTTPException(status_code=400,detail="job_post_id is required")
|
||||||
rows=await JobAssignments.fetch_by_job(self.session,job_post_id)
|
role=self._job_role(assignment_role) if assignment_role else None
|
||||||
return [serialize_job_assignment(r) for r in rows]
|
rows=await JobAssignments.fetch_by_job(
|
||||||
|
self.session,job_post_id,current_only=current_only,assignment_role=role,
|
||||||
|
)
|
||||||
|
names=await Users.names_by_ids(
|
||||||
|
self.session,[r.user_id for r in rows]+[r.assigned_by for r in rows],
|
||||||
|
)
|
||||||
|
return [serialize_job_assignment(r,names=names) for r in rows]
|
||||||
|
|
||||||
async def create_job_assignment(self,payload,current_user):
|
async def create_job_assignment(self,payload,current_user):
|
||||||
user_id=payload.get("user_id")
|
user_id=payload.get("user_id")
|
||||||
job_post_id=payload.get("job_post_id")
|
job_post_id=payload.get("job_post_id")
|
||||||
if not user_id or not job_post_id:
|
if not user_id or not job_post_id:
|
||||||
raise HTTPException(status_code=422,detail="user_id and job_post_id are required")
|
raise HTTPException(status_code=422,detail="user_id and job_post_id are required")
|
||||||
await self._require_recruiter(user_id)
|
job=await JobPosts.get_job_post_by_id(self.session,job_post_id)
|
||||||
fields={
|
if not job or job.is_deleted:
|
||||||
"job_post_id":JobAssignments._as_uuid(job_post_id),
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
"user_id":JobAssignments._as_uuid(user_id),
|
role=self._job_role(payload.get("assignment_role"))
|
||||||
"assignment_role":payload.get("assignment_role") or "primary_recruiter",
|
await self.require_role(user_id,JOB_ASSIGNMENT_ROLES[role],"user_id")
|
||||||
"assigned_by":JobAssignments._as_uuid(
|
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||||
current_user.get("id") if isinstance(current_user,dict) else None
|
row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
|
||||||
),
|
column=JOB_OWNER_COLUMN[role]
|
||||||
}
|
await JobPosts.update_job_post(self.session,job_post_id,{column:user_id})
|
||||||
if not fields["job_post_id"] or not fields["user_id"] or not fields["assigned_by"]:
|
names=await Users.names_by_ids(
|
||||||
raise HTTPException(status_code=422,detail="Invalid job_post_id, user_id, or assigned_by")
|
self.session,[row.user_id,row.assigned_by] if row else [],
|
||||||
row=await JobAssignments.insert_assignment(self.session,fields)
|
)
|
||||||
return serialize_job_assignment(row)
|
return serialize_job_assignment(row,names=names) if row else None
|
||||||
|
|
||||||
async def list_application_assignments(self,inbox_id):
|
async def list_application_assignments(self,inbox_id):
|
||||||
if inbox_id is None:
|
if inbox_id is None:
|
||||||
raise HTTPException(status_code=400,detail="inbox_id is required")
|
raise HTTPException(status_code=400,detail="inbox_id is required")
|
||||||
rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id))
|
rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id))
|
||||||
return [serialize_application_assignment(r) for r in rows]
|
names=await Users.names_by_ids(
|
||||||
|
self.session,[r.user_id for r in rows]+[r.assigned_by for r in rows],
|
||||||
|
)
|
||||||
|
return [serialize_application_assignment(r,names=names) for r in rows]
|
||||||
|
|
||||||
async def create_application_assignment(self,payload,current_user):
|
async def create_application_assignment(self,payload,current_user):
|
||||||
user_id=payload.get("user_id")
|
user_id=payload.get("user_id")
|
||||||
inbox_id=payload.get("inbox_id")
|
inbox_id=payload.get("inbox_id")
|
||||||
if not user_id or inbox_id is None:
|
if not user_id or inbox_id is None:
|
||||||
raise HTTPException(status_code=422,detail="user_id and inbox_id are required")
|
raise HTTPException(status_code=422,detail="user_id and inbox_id are required")
|
||||||
await self._require_recruiter(user_id)
|
await self.require_role(user_id,EnumRoles.RECRUITER,"user_id")
|
||||||
fields={
|
fields={
|
||||||
"inbox_id":int(inbox_id),
|
"inbox_id":int(inbox_id),
|
||||||
"user_id":ApplicationAssignments._as_uuid(user_id),
|
"user_id":ApplicationAssignments._as_uuid(user_id),
|
||||||
|
|
@ -66,4 +137,5 @@ class Assignment:
|
||||||
if not fields["user_id"] or not fields["assigned_by"]:
|
if not fields["user_id"] or not fields["assigned_by"]:
|
||||||
raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by")
|
raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by")
|
||||||
row=await ApplicationAssignments.insert_assignment(self.session,fields)
|
row=await ApplicationAssignments.insert_assignment(self.session,fields)
|
||||||
return serialize_application_assignment(row)
|
names=await Users.names_by_ids(self.session,[row.user_id,row.assigned_by])
|
||||||
|
return serialize_application_assignment(row,names=names)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -175,6 +175,45 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
return counts
|
return counts
|
||||||
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
|
||||||
|
async def counts_by_application_status(
|
||||||
|
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||||
|
):
|
||||||
|
"""Current-stage counts for the same manual-upload population the board uses.
|
||||||
|
|
||||||
|
Inner-joined to a job post, same as count_by_status. Optional department /
|
||||||
|
recruiter / created_at window sit on top; with none of those this matches
|
||||||
|
the board's manual_upload column.
|
||||||
|
"""
|
||||||
|
from users.models import Users
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
qry=(
|
||||||
|
select(cls.status,func.count())
|
||||||
|
.select_from(cls)
|
||||||
|
.join(Users,cls.user_id==Users.id)
|
||||||
|
.join(JobPosts,cls.job_post_id==JobPosts.id)
|
||||||
|
)
|
||||||
|
if from_date is not None:
|
||||||
|
qry=qry.where(cls.created_at>=from_date)
|
||||||
|
if to_date is not None:
|
||||||
|
qry=qry.where(cls.created_at<to_date)
|
||||||
|
if department:
|
||||||
|
qry=qry.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:
|
||||||
|
qry=qry.where(JobPosts.current_recruiter_id==rid)
|
||||||
|
qry=qry.group_by(cls.status)
|
||||||
|
result=await session.execute(qry)
|
||||||
|
counts={}
|
||||||
|
for status,n in result.all():
|
||||||
|
key=(status or "").strip() or "UNKNOWN"
|
||||||
|
counts[key]=counts.get(key,0)+int(n or 0)
|
||||||
|
return counts
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||||
if record_id in (None, ""):
|
if record_id in (None, ""):
|
||||||
|
|
@ -290,6 +329,18 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
result = await session.execute(select(cls).where(cls.id == uid))
|
result = await session.execute(select(cls).where(cls.id == uid))
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_ids(cls, session: AsyncSession, ids):
|
||||||
|
keys = []
|
||||||
|
for raw in ids or []:
|
||||||
|
uid = cls._as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
keys.append(uid)
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
result = await session.execute(select(cls).where(cls.id.in_(keys)))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_email_and_job(cls, session: AsyncSession, email: str, job_post_id):
|
async def get_by_email_and_job(cls, session: AsyncSession, email: str, job_post_id):
|
||||||
"""Idempotency for form / re-import promotes against the same role."""
|
"""Idempotency for form / re-import promotes against the same role."""
|
||||||
|
|
@ -380,25 +431,27 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# ---- CV bank -----------------------------------------------------------
|
# ---- CV bank -----------------------------------------------------------
|
||||||
# apply_via="cv_bank" rows are a private store of CVs with NO job, NO user
|
# apply_via="cv_bank" marks origin: the CV Import "No job" tab. Unassigned
|
||||||
# account and NO inbox entry — deliberately invisible to Candidates,
|
# rows (job_post_id IS NULL) are the bank; Job Matching assigns a job_post_id
|
||||||
# Pipeline (whose list inner-joins Users/JobPosts) and the Inbox. They wait
|
# (and a user account) so get_all's Users/JobPosts inner joins pick them up
|
||||||
# until a recruiter picks them up; email is captured only when the CV
|
# as normal applications. apply_via stays "cv_bank" so Matching can still
|
||||||
# contains one.
|
# list them. No inbox entry.
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email,
|
async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email,
|
||||||
candidate_name, full_text, file_name,
|
candidate_name, full_text, file_name,
|
||||||
created_by, pdf_bytes,
|
created_by, pdf_bytes,
|
||||||
content_type="application/pdf"):
|
content_type="application/pdf",
|
||||||
|
linkedin_url=None):
|
||||||
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit.
|
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit.
|
||||||
file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload).
|
file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload).
|
||||||
|
|
||||||
When the CV carries an email, the candidate ACCOUNT is created/reused
|
linkedin_url is the employment-agent extraction (None when the CV has
|
||||||
(same pattern as create_manual_upload_candidate) so the person shows
|
none — never a constructed slug). When the CV carries an email, the
|
||||||
up on the Candidates screen; unlike an application there is still no
|
candidate ACCOUNT is created/reused so the person shows up on the
|
||||||
inbox entry, no scoring, and no setup email. A CV with no detectable
|
Candidates screen; unlike an application there is still no inbox entry,
|
||||||
email banks fine and simply stays account-less."""
|
no scoring, and no setup email. A CV with no detectable email banks
|
||||||
|
fine and simply stays account-less."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from role.models import EnumRoles, Roles
|
from role.models import EnumRoles, Roles
|
||||||
|
|
@ -427,12 +480,21 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
user.is_active = True
|
user.is_active = True
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|
||||||
|
url = (linkedin_url or "").strip() or None
|
||||||
|
if url:
|
||||||
|
linkedin_slug = slug_from_url(url) or NO_SLUG
|
||||||
|
else:
|
||||||
|
linkedin_slug = primary_slug_from_text(full_text or "")
|
||||||
|
if user and url:
|
||||||
|
await Users.set_linkedin_url_if_empty(session, user_id=user.id, url=url)
|
||||||
|
|
||||||
row = cls(
|
row = cls(
|
||||||
candidate_email=email,
|
candidate_email=email,
|
||||||
candidate_name=(candidate_name or "").strip() or (email or ""),
|
candidate_name=(candidate_name or "").strip() or (email or ""),
|
||||||
job_post_id=None,
|
job_post_id=None,
|
||||||
full_text=full_text or "",
|
full_text=full_text or "",
|
||||||
linkedin_slug=primary_slug_from_text(full_text or ""),
|
linkedin_slug=linkedin_slug,
|
||||||
|
linkedin_url=url,
|
||||||
apply_via="cv_bank",
|
apply_via="cv_bank",
|
||||||
user_id=user.id if user else None,
|
user_id=user.id if user else None,
|
||||||
created_by=cls._as_uuid(created_by),
|
created_by=cls._as_uuid(created_by),
|
||||||
|
|
@ -455,14 +517,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
|
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
|
||||||
|
"""Unassigned No-job CVs only — assigned rows leave the bank for Matching."""
|
||||||
|
bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None))
|
||||||
total = (
|
total = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(func.count()).select_from(cls).where(cls.apply_via == "cv_bank")
|
select(func.count()).select_from(cls).where(*bank)
|
||||||
)
|
)
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(cls)
|
select(cls)
|
||||||
.where(cls.apply_via == "cv_bank")
|
.where(*bank)
|
||||||
.order_by(cls.created_at.desc(), cls.id.desc())
|
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
|
|
@ -470,12 +534,104 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
return list(result.scalars().all()), total
|
return list(result.scalars().all()), total
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete_bank_cv(cls, session: AsyncSession, record_id):
|
async def list_matching(cls, session: AsyncSession, *, assigned=None,
|
||||||
"""Hard delete, bank rows only — never reachable for application rows.
|
search=None, limit=100, offset=0):
|
||||||
The cv_bank_files row goes with it via ON DELETE CASCADE."""
|
"""No-job-tab origin (`apply_via=cv_bank`). `assigned` is tri-valued:
|
||||||
|
None = all, False = still in the bank, True = job_post_id set."""
|
||||||
|
filters = [cls.apply_via == "cv_bank"]
|
||||||
|
if assigned is True:
|
||||||
|
filters.append(cls.job_post_id.is_not(None))
|
||||||
|
elif assigned is False:
|
||||||
|
filters.append(cls.job_post_id.is_(None))
|
||||||
|
if search and str(search).strip():
|
||||||
|
like = f"%{str(search).strip()}%"
|
||||||
|
filters.append(or_(
|
||||||
|
cls.candidate_name.ilike(like),
|
||||||
|
cls.candidate_email.ilike(like),
|
||||||
|
cls.file_name.ilike(like),
|
||||||
|
))
|
||||||
|
total = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count()).select_from(cls).where(*filters)
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all()), total
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _ensure_bank_user(cls, session: AsyncSession, row):
|
||||||
|
"""Candidate USER so get_all / Candidates can see the row after assign.
|
||||||
|
insert_user commits; caller must reload `row` afterwards."""
|
||||||
|
if row.user_id:
|
||||||
|
return None
|
||||||
|
import os
|
||||||
|
|
||||||
|
from role.models import EnumRoles, Roles
|
||||||
|
from users.models import Users
|
||||||
|
from users.plugins import hash_password
|
||||||
|
|
||||||
|
email = (row.candidate_email or "").strip().lower()
|
||||||
|
if not email:
|
||||||
|
email = f"cvbank-{row.id.hex}@no-email.local"
|
||||||
|
user = await Users.get_user_by_email(session, email)
|
||||||
|
if not user:
|
||||||
|
role = await Roles.get_role_by_name(session, EnumRoles.CANDIDATE.value)
|
||||||
|
name = (row.candidate_name or "").strip() or (row.file_name or "").strip() or email
|
||||||
|
user = await Users.insert_user(session, {
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"role_id": role.id if role else 8,
|
||||||
|
"password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")),
|
||||||
|
"is_active": True,
|
||||||
|
"is_approved": True,
|
||||||
|
"is_deleted": False,
|
||||||
|
})
|
||||||
|
elif user.is_deleted or not user.is_active:
|
||||||
|
user.is_deleted = False
|
||||||
|
user.is_active = True
|
||||||
|
session.add(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def assign_job_post(cls, session: AsyncSession, record_id, job_post_id):
|
||||||
|
"""Set job_post_id on a CV-bank row. None unassigns (back to the bank).
|
||||||
|
Origin apply_via stays cv_bank. Creates/reuses a candidate user so the
|
||||||
|
row joins like any other manual_upload_candidate application."""
|
||||||
row = await cls.get_by_id(session, record_id)
|
row = await cls.get_by_id(session, record_id)
|
||||||
if not row or row.apply_via != "cv_bank":
|
if not row or row.apply_via != "cv_bank":
|
||||||
return None
|
return None
|
||||||
|
jid = cls._as_uuid(job_post_id) if job_post_id not in (None, "") else None
|
||||||
|
user = await cls._ensure_bank_user(session, row)
|
||||||
|
row = await cls.get_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if user is not None:
|
||||||
|
row.user_id = user.id
|
||||||
|
if not (row.candidate_email or "").strip() and user.email:
|
||||||
|
row.candidate_email = user.email
|
||||||
|
if not (row.candidate_name or "").strip() and user.name:
|
||||||
|
row.candidate_name = user.name
|
||||||
|
row.job_post_id = jid
|
||||||
|
row.status = "PENDING" if jid else "BANKED"
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def delete_bank_cv(cls, session: AsyncSession, record_id):
|
||||||
|
"""Hard delete unassigned bank rows only — assigned rows are applications.
|
||||||
|
The cv_bank_files row goes with it via ON DELETE CASCADE."""
|
||||||
|
row = await cls.get_by_id(session, record_id)
|
||||||
|
if not row or row.apply_via != "cv_bank" or row.job_post_id is not None:
|
||||||
|
return None
|
||||||
file_row = await CvBankFiles.get(session, row.id)
|
file_row = await CvBankFiles.get(session, row.id)
|
||||||
if file_row:
|
if file_row:
|
||||||
await session.delete(file_row)
|
await session.delete(file_row)
|
||||||
|
|
@ -685,6 +841,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(
|
||||||
|
|
@ -725,6 +886,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,
|
||||||
|
|
@ -733,6 +916,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,
|
||||||
):
|
):
|
||||||
|
|
@ -743,6 +927,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 = (
|
||||||
|
|
@ -755,6 +941,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.
|
||||||
|
|
@ -1117,6 +1338,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.
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,28 @@ def serialize_candidate(row) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_matching_candidate(row, job_post=None) -> Dict[str,Any]:
|
||||||
|
"""CV-bank origin row for Job Matching. assigned_job_post_id is job_posts.id."""
|
||||||
|
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
|
||||||
|
job_payload=serialize_job_post(job_post) if job_post else None
|
||||||
|
return {
|
||||||
|
"id":str(row.id),
|
||||||
|
"name":name,
|
||||||
|
"email":(row.candidate_email or "").strip() or None,
|
||||||
|
"file_name":(row.file_name or "").strip() or None,
|
||||||
|
"file_path":(row.file_path or "").strip() or None,
|
||||||
|
"resume_text":row.full_text or None,
|
||||||
|
"linkedin_url":row.linkedin_url or None,
|
||||||
|
"apply_via":row.apply_via,
|
||||||
|
"status":row.status or None,
|
||||||
|
"user_id":str(row.user_id) if row.user_id else None,
|
||||||
|
"assigned_job_post_id":str(row.job_post_id) if row.job_post_id else None,
|
||||||
|
"assigned_job_post":job_payload,
|
||||||
|
"created_at":row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at":row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
||||||
return {
|
return {
|
||||||
"id":str(row.id) if row.id else None,
|
"id":str(row.id) if row.id else None,
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,6 @@ from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pypdf import PdfReader
|
from pypdf import PdfReader
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlmodel import true
|
|
||||||
from app.core.errors import ATSError,ErrorCode
|
from app.core.errors import ATSError,ErrorCode
|
||||||
from app.models.scoring import CompletedCandidate
|
from app.models.scoring import CompletedCandidate
|
||||||
from app.services.pdf import extract_resume,sanitize_filename
|
from app.services.pdf import extract_resume,sanitize_filename
|
||||||
|
|
@ -26,7 +23,7 @@ from job.candidate.plugins import (
|
||||||
get_scoring_settings,
|
get_scoring_settings,
|
||||||
normalize_spaced_text,
|
normalize_spaced_text,
|
||||||
)
|
)
|
||||||
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate
|
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
from job.job_post.serializers import serialize_job_post
|
from job.job_post.serializers import serialize_job_post
|
||||||
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
|
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
|
||||||
|
|
@ -46,7 +43,7 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
|
||||||
|
|
||||||
|
|
||||||
async def parse_linkedin_url_from_cv(resume_text) -> str | None:
|
async def parse_linkedin_url_from_cv(resume_text) -> str | None:
|
||||||
"""Employment-agent `linkedin_url` key from parsed CV text. None if absent or the call fails."""
|
"""Employment-agent `linkedin_url` from CV text. None if absent, sentinel, invented, or the call fails."""
|
||||||
text=(resume_text or "").strip()
|
text=(resume_text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
@ -988,22 +985,13 @@ class CandidateView:
|
||||||
notes=[]
|
notes=[]
|
||||||
uid=Notes._as_uuid(user_id) if user_id else None
|
uid=Notes._as_uuid(user_id) if user_id else None
|
||||||
if uid is not None:
|
if uid is not None:
|
||||||
result=await self.session.execute(
|
notes=[serialize_note(r) for r in await Notes.get_notes_by_user(self.session,uid)]
|
||||||
select(Notes)
|
|
||||||
.options(selectinload(Notes.author))
|
|
||||||
.where(Notes.user_id==uid)
|
|
||||||
.order_by(Notes.created_at.desc())
|
|
||||||
)
|
|
||||||
notes=[serialize_note(r) for r in result.scalars().all()]
|
|
||||||
|
|
||||||
# ATS score from inbox denorm / ats_results via Inbox.ats_id — never from
|
# ATS score from inbox denorm / ats_results via Inbox.ats_id — never from
|
||||||
# a Candidates join on message id. Keywords live on the scored Candidates
|
# a Candidates join on message id. Keywords live on the scored Candidates
|
||||||
# row: candidate_id when set, else email+job for the matched-user path.
|
# row: candidate_id when set, else email+job for the matched-user path.
|
||||||
ats_ids=[r.ats_id for r in records if getattr(r,"ats_id",None)]
|
ats_ids=[r.ats_id for r in records if getattr(r,"ats_id",None)]
|
||||||
ats_rows=[]
|
ats_rows=await AtsResults.get_by_ids(self.session,ats_ids) if ats_ids else []
|
||||||
if ats_ids:
|
|
||||||
result=await self.session.execute(select(AtsResults).where(AtsResults.id.in_(ats_ids)))
|
|
||||||
ats_rows=list(result.scalars().all())
|
|
||||||
assigned_uid=AtsResults._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
|
assigned_uid=AtsResults._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
|
||||||
chosen=None
|
chosen=None
|
||||||
if assigned_uid is not None:
|
if assigned_uid is not None:
|
||||||
|
|
@ -1088,3 +1076,52 @@ class CandidateView:
|
||||||
raise HTTPException(status_code=404,detail="Not found")
|
raise HTTPException(status_code=404,detail="Not found")
|
||||||
name=(entry.get("name") or path.name).strip() or path.name
|
name=(entry.get("name") or path.name).strip() or path.name
|
||||||
return path,name
|
return path,name
|
||||||
|
|
||||||
|
async def list_matching(self,assigned=None,search=None,limit=10,offset=0):
|
||||||
|
rows,total=await Manual_UPLOAD_CANDIDATE.list_matching(
|
||||||
|
self.session,assigned=assigned,search=search,limit=limit,offset=offset,
|
||||||
|
)
|
||||||
|
job_ids=[str(r.job_post_id) for r in rows if r.job_post_id]
|
||||||
|
posts=await JobPosts.get_by_ids(self.session,job_ids,active_only=False) if job_ids else []
|
||||||
|
by_id={str(p.id):p for p in posts}
|
||||||
|
data=[
|
||||||
|
serialize_matching_candidate(r,by_id.get(str(r.job_post_id)) if r.job_post_id else None)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return data,total
|
||||||
|
|
||||||
|
async def get_matching(self,record_id):
|
||||||
|
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id)
|
||||||
|
if not row or row.apply_via!="cv_bank":
|
||||||
|
raise HTTPException(status_code=404,detail="CV not found")
|
||||||
|
job_post=None
|
||||||
|
if row.job_post_id:
|
||||||
|
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
|
||||||
|
return serialize_matching_candidate(row,job_post)
|
||||||
|
|
||||||
|
async def assign_matching(self,record_id,job_post_id,current_user=None):
|
||||||
|
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id)
|
||||||
|
if not row or row.apply_via!="cv_bank":
|
||||||
|
raise HTTPException(status_code=404,detail="CV not found")
|
||||||
|
job_post=None
|
||||||
|
if job_post_id not in (None,""):
|
||||||
|
job_post=await JobPosts.get_job_post_by_id(self.session,str(job_post_id))
|
||||||
|
if not job_post or job_post.is_deleted:
|
||||||
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
|
was_unassigned=row.job_post_id is None
|
||||||
|
row=await Manual_UPLOAD_CANDIDATE.assign_job_post(self.session,record_id,job_post_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404,detail="CV not found")
|
||||||
|
if job_post is None and row.job_post_id:
|
||||||
|
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
|
||||||
|
if was_unassigned and row.job_post_id and row.user_id:
|
||||||
|
title=(job_post.title if job_post else "") or str(row.job_post_id)
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.CANDIDATE_CREATED.value,
|
||||||
|
actor_id=current_user,user_id=row.user_id,
|
||||||
|
manual_upload_candidate_id=row.id,
|
||||||
|
entity_type="manual_upload_candidate",entity_id=row.id,
|
||||||
|
to_value=title,
|
||||||
|
description="cv_bank",commit=True,
|
||||||
|
)
|
||||||
|
return serialize_matching_candidate(row,job_post)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from job.candidate.models import Feedback
|
from job.candidate.models import Feedback
|
||||||
from job.feedback.models import FeedbackTemplates
|
from job.feedback.models import FeedbackTemplates
|
||||||
|
|
@ -15,13 +13,7 @@ class FeedbackView:
|
||||||
self.session=session
|
self.session=session
|
||||||
|
|
||||||
async def _load(self,record_id):
|
async def _load(self,record_id):
|
||||||
uid=Feedback._as_uuid(record_id)
|
return await Feedback.get_feedback_by_id(self.session,record_id)
|
||||||
if uid is None:
|
|
||||||
return None
|
|
||||||
result=await self.session.execute(
|
|
||||||
select(Feedback).options(selectinload(Feedback.user)).where(Feedback.id==uid)
|
|
||||||
)
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
async def get_feedback(self,feedback_id=None,inbox_id=None):
|
async def get_feedback(self,feedback_id=None,inbox_id=None):
|
||||||
if feedback_id:
|
if feedback_id:
|
||||||
|
|
@ -31,13 +23,8 @@ class FeedbackView:
|
||||||
return serialize_feedback(row)
|
return serialize_feedback(row)
|
||||||
if inbox_id is None:
|
if inbox_id is None:
|
||||||
raise HTTPException(status_code=400,detail="feedback_id or inbox_id is required")
|
raise HTTPException(status_code=400,detail="feedback_id or inbox_id is required")
|
||||||
result=await self.session.execute(
|
rows=await Feedback.get_feedback_by_inbox(self.session,int(inbox_id))
|
||||||
select(Feedback)
|
return [serialize_feedback(r) for r in rows]
|
||||||
.options(selectinload(Feedback.user))
|
|
||||||
.where(Feedback.inbox_id==int(inbox_id))
|
|
||||||
.order_by(Feedback.created_at.desc())
|
|
||||||
)
|
|
||||||
return [serialize_feedback(r) for r in result.scalars().all()]
|
|
||||||
|
|
||||||
async def create_feedback(self,payload,current_user):
|
async def create_feedback(self,payload,current_user):
|
||||||
fields={
|
fields={
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from inbox.models import Inbox
|
from inbox.models import Inbox
|
||||||
from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE
|
from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE
|
||||||
|
|
@ -117,10 +116,5 @@ class HistoryRecorder:
|
||||||
self.session, user_id, limit=limit, offset=offset
|
self.session, user_id, limit=limit, offset=offset
|
||||||
)
|
)
|
||||||
actor_ids = {r.actor_id for r in rows if r.actor_id}
|
actor_ids = {r.actor_id for r in rows if r.actor_id}
|
||||||
names = {}
|
names = await Users.names_by_ids(self.session, actor_ids)
|
||||||
if actor_ids:
|
return [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows], total
|
||||||
result = await self.session.execute(
|
|
||||||
select(Users.id, Users.name).where(Users.id.in_(actor_ids))
|
|
||||||
)
|
|
||||||
names = {uid: name for uid, name in result.all()}
|
|
||||||
return [serialize_history(r, actor_name=names.get(r.actor_id)) for r in rows], total
|
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
@ -36,6 +36,7 @@ COLUMNS = [
|
||||||
("Status", 10),
|
("Status", 10),
|
||||||
("Publishing", 12),
|
("Publishing", 12),
|
||||||
("Recruiter", 18),
|
("Recruiter", 18),
|
||||||
|
("Hiring Manager", 18),
|
||||||
("Created By", 18),
|
("Created By", 18),
|
||||||
("Created", 13),
|
("Created", 13),
|
||||||
("Requirements", 46),
|
("Requirements", 46),
|
||||||
|
|
@ -126,6 +127,7 @@ def build_jobs_workbook(rows) -> bytes:
|
||||||
STATUS_LABELS.get(status_key, status_key),
|
STATUS_LABELS.get(status_key, status_key),
|
||||||
row.get("status") or "",
|
row.get("status") or "",
|
||||||
row.get("recruiter_name") or "",
|
row.get("recruiter_name") or "",
|
||||||
|
row.get("hiring_manager_name") or "",
|
||||||
row.get("created_by_name") or "",
|
row.get("created_by_name") or "",
|
||||||
_created(row),
|
_created(row),
|
||||||
_bullets(row.get("requirements")),
|
_bullets(row.get("requirements")),
|
||||||
|
|
@ -145,7 +147,7 @@ def build_jobs_workbook(rows) -> bytes:
|
||||||
status_cell.alignment = center
|
status_cell.alignment = center
|
||||||
if status_key in STATUS_COLORS:
|
if status_key in STATUS_COLORS:
|
||||||
status_cell.font = Font(bold=True, color=STATUS_COLORS[status_key])
|
status_cell.font = Font(bold=True, color=STATUS_COLORS[status_key])
|
||||||
created_cell = ws.cell(row=r, column=13)
|
created_cell = ws.cell(row=r, column=14)
|
||||||
if created_cell.value is not None:
|
if created_cell.value is not None:
|
||||||
created_cell.number_format = "dd mmm yyyy"
|
created_cell.number_format = "dd mmm yyyy"
|
||||||
for c in (14, 15, 16):
|
for c in (14, 15, 16):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -20,12 +22,12 @@ class JobPosts(SQLModel, table=True):
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
title: str = Field(index=True)
|
title: str = Field(index=True)
|
||||||
|
|
||||||
# foreign_keys is required, not decoration: current_recruiter_id below is a
|
# foreign_keys is required, not decoration: current_recruiter_id and
|
||||||
# SECOND foreign key into users.id, so the join condition is ambiguous without
|
# hiring_manager_id below are extra FKs into users.id, so the join is
|
||||||
# it and every mapper fails to initialize. `user` is the AUTHOR of the post —
|
# ambiguous without it and every mapper fails to initialize. `user` is the
|
||||||
# current_recruiter_id is deliberately a bare column with no relationship of
|
# AUTHOR of the post. The recruiter and hiring-manager columns stay bare —
|
||||||
# its own, because Users already carries five selectin relations that load on
|
# Users already carries five selectin relations that load on every
|
||||||
# every authenticated request. Same pairing as Notes.user / Notes.author.
|
# authenticated request. Same pairing as Notes.user / Notes.author.
|
||||||
user: Optional["Users"] = Relationship(
|
user: Optional["Users"] = Relationship(
|
||||||
back_populates="job_posts",
|
back_populates="job_posts",
|
||||||
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
|
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
|
||||||
|
|
@ -49,14 +51,19 @@ 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"})
|
||||||
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
||||||
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
|
# Who is working the req now (swappable). History lives in job_assignments
|
||||||
|
# with assignment_role=primary_recruiter; this column is the current pointer.
|
||||||
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||||
|
# Who owns the requisition (stable). Required at create. History lives in
|
||||||
|
# job_assignments with assignment_role=hiring_manager.
|
||||||
|
hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
created_by: uuid.UUID = Field(foreign_key="users.id")
|
created_by: uuid.UUID = Field(foreign_key="users.id")
|
||||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
@ -136,6 +143,7 @@ class JobPosts(SQLModel, table=True):
|
||||||
department: str | None = None,
|
department: str | None = None,
|
||||||
requisition_status: str | None = None,
|
requisition_status: str | None = None,
|
||||||
employment_type: str | None = None,
|
employment_type: str | None = None,
|
||||||
|
hiring_manager_id: uuid.UUID | None = None,
|
||||||
):
|
):
|
||||||
if ids:
|
if ids:
|
||||||
rows = await cls.get_by_ids(session, ids, active_only=active_only)
|
rows = await cls.get_by_ids(session, ids, active_only=active_only)
|
||||||
|
|
@ -157,6 +165,8 @@ class JobPosts(SQLModel, table=True):
|
||||||
statement = statement.where(cls.requisition_status == requisition_status)
|
statement = statement.where(cls.requisition_status == requisition_status)
|
||||||
if employment_type:
|
if employment_type:
|
||||||
statement = statement.where(cls.employment_type == employment_type)
|
statement = statement.where(cls.employment_type == employment_type)
|
||||||
|
if hiring_manager_id is not None:
|
||||||
|
statement = statement.where(cls.hiring_manager_id == hiring_manager_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.order_by(cls.created_at.desc())
|
statement = statement.order_by(cls.created_at.desc())
|
||||||
|
|
@ -185,10 +195,118 @@ class JobPosts(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
|
||||||
|
"""Open requisitions per hiring manager, keyed by users.id."""
|
||||||
|
uids = [u for u in (user_ids or []) if u]
|
||||||
|
if not uids:
|
||||||
|
return {}
|
||||||
|
statement = (
|
||||||
|
select(cls.hiring_manager_id, func.count())
|
||||||
|
.where(
|
||||||
|
cls.hiring_manager_id.in_(uids),
|
||||||
|
cls.requisition_status == "open",
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
.group_by(cls.hiring_manager_id)
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return {uid: int(n or 0) for uid, n in result.all()}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_by_current_recruiter(
|
||||||
|
cls, session: AsyncSession, recruiter_id, *, status, department=None,
|
||||||
|
from_date=None, to_date=None,
|
||||||
|
):
|
||||||
|
"""Requisitions owned by current_recruiter_id in one requisition_status."""
|
||||||
|
uid = cls._as_uuid(recruiter_id)
|
||||||
|
if uid is None:
|
||||||
|
return 0
|
||||||
|
statement = select(func.count()).select_from(cls).where(
|
||||||
|
cls.current_recruiter_id == uid,
|
||||||
|
cls.requisition_status == status,
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
if department:
|
||||||
|
statement = statement.where(cls.department == department)
|
||||||
|
if from_date is not None:
|
||||||
|
statement = statement.where(cls.closed_at >= from_date)
|
||||||
|
if to_date is not None:
|
||||||
|
statement = statement.where(cls.closed_at < to_date)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _scoped(cls, statement, department=None, recruiter_id=None):
|
||||||
|
if department:
|
||||||
|
statement = statement.where(cls.department == department)
|
||||||
|
uid = cls._as_uuid(recruiter_id) if recruiter_id is not None else None
|
||||||
|
if uid is not None:
|
||||||
|
statement = statement.where(cls.current_recruiter_id == uid)
|
||||||
|
return statement
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_requisitions(
|
||||||
|
cls, session: AsyncSession, status=None, department=None, recruiter_id=None,
|
||||||
|
from_date=None, to_date=None, *, closed_in_window=False,
|
||||||
|
):
|
||||||
|
statement = select(func.count()).select_from(cls).where(cls.is_deleted == False) # noqa: E712
|
||||||
|
if status:
|
||||||
|
statement = statement.where(cls.requisition_status == status)
|
||||||
|
statement = cls._scoped(statement, department, recruiter_id)
|
||||||
|
if closed_in_window:
|
||||||
|
if from_date is not None:
|
||||||
|
statement = statement.where(cls.closed_at >= from_date)
|
||||||
|
if to_date is not None:
|
||||||
|
statement = statement.where(cls.closed_at < to_date)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_open_snapshot(cls, session: AsyncSession, as_of, department=None, recruiter_id=None):
|
||||||
|
"""Jobs that existed and were still open at `as_of` (best-effort)."""
|
||||||
|
statement = select(func.count()).select_from(cls).where(
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
cls.created_at < as_of,
|
||||||
|
or_(cls.closed_at.is_(None), cls.closed_at >= as_of),
|
||||||
|
cls.requisition_status == RequisitionStatus.OPEN.value,
|
||||||
|
)
|
||||||
|
statement = cls._scoped(statement, department, recruiter_id)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def avg_time_to_fill(
|
||||||
|
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||||
|
):
|
||||||
|
days = func.extract("epoch", cls.closed_at - cls.created_at) / 86400.0
|
||||||
|
statement = select(func.avg(days)).select_from(cls).where(
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
cls.requisition_status.in_((
|
||||||
|
RequisitionStatus.CLOSED.value,
|
||||||
|
RequisitionStatus.COMPLETED.value,
|
||||||
|
)),
|
||||||
|
cls.closed_at.is_not(None),
|
||||||
|
)
|
||||||
|
if from_date is not None:
|
||||||
|
statement = statement.where(cls.closed_at >= from_date)
|
||||||
|
if to_date is not None:
|
||||||
|
statement = statement.where(cls.closed_at < to_date)
|
||||||
|
statement = cls._scoped(statement, department, recruiter_id)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
value = result.scalar_one()
|
||||||
|
return float(value) if value is not None else None
|
||||||
|
|
||||||
@classmethod
|
@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)
|
||||||
|
|
||||||
|
|
@ -263,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,7 +1,19 @@
|
||||||
|
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),
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
|
# Talent Pool / candidate filters key off attached job_posts.department.
|
||||||
|
"department": row.department or None,
|
||||||
"employment_type": row.employment_type,
|
"employment_type": row.employment_type,
|
||||||
"location": row.location,
|
"location": row.location,
|
||||||
"experience_min": row.experience_min,
|
"experience_min": row.experience_min,
|
||||||
|
|
@ -27,12 +39,12 @@ def serialize_job_post(row) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict:
|
def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
|
||||||
"""Requisition view of a job post, for the Jobs screen.
|
"""Requisition view of a job post, for the Jobs screen.
|
||||||
|
|
||||||
Deliberately separate from serialize_job_post: that payload is shared by the
|
Deliberately separate from serialize_job_post: that payload is shared by the
|
||||||
inbox, candidate and matching paths, and widening it would change five
|
inbox, candidate and matching paths. department is the one shared field —
|
||||||
response shapes at once.
|
talent-pool filters key off it on attached job_posts.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
|
|
@ -56,9 +68,26 @@ def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict:
|
||||||
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
|
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
|
||||||
"current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None,
|
"current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None,
|
||||||
"recruiter_name": recruiter_name,
|
"recruiter_name": recruiter_name,
|
||||||
|
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
|
||||||
|
"hiring_manager_name": hiring_manager_name,
|
||||||
"applicant_count": applicant_count,
|
"applicant_count": applicant_count,
|
||||||
"created_by": str(row.created_by) if row.created_by else None,
|
"created_by": str(row.created_by) if row.created_by else None,
|
||||||
"created_by_name": row.user.name if getattr(row, "user", None) else None,
|
"created_by_name": row.user.name if getattr(row, "user", None) else None,
|
||||||
"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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
@ -10,7 +11,10 @@ from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.job_post.models import JobPostImages,JobPosts,SocialPlatform
|
from job.assignment.views import Assignment
|
||||||
|
from job.job_post.enums import RequisitionStatus
|
||||||
|
from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform
|
||||||
|
from role.models import EnumRoles
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
from job.job_post.plugins import (
|
from job.job_post.plugins import (
|
||||||
BufferError,
|
BufferError,
|
||||||
|
|
@ -22,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")
|
||||||
|
|
@ -60,6 +64,8 @@ class JobPostCreate(BaseModel):
|
||||||
scheduler_time: time | None = time(0, 0, 0)
|
scheduler_time: time | None = time(0, 0, 0)
|
||||||
scheduler_date: date | None = None
|
scheduler_date: date | None = None
|
||||||
due_at: str | None = None
|
due_at: str | None = None
|
||||||
|
hiring_manager_id: UUID
|
||||||
|
current_recruiter_id: UUID | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_mode_and_due_at(self):
|
def validate_mode_and_due_at(self):
|
||||||
|
|
@ -138,7 +144,24 @@ class JobPost:
|
||||||
# Column default is "linkedin"; an unpublished requisition must not
|
# Column default is "linkedin"; an unpublished requisition must not
|
||||||
# masquerade as a LinkedIn post.
|
# masquerade as a LinkedIn post.
|
||||||
fields["platform"]="internal"
|
fields["platform"]="internal"
|
||||||
|
|
||||||
|
assignment=Assignment(self.session)
|
||||||
|
hm=await assignment.require_role(
|
||||||
|
payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id",
|
||||||
|
)
|
||||||
|
fields["hiring_manager_id"]=hm.id
|
||||||
|
rec=None
|
||||||
|
if payload.get("current_recruiter_id"):
|
||||||
|
rec=await assignment.require_role(
|
||||||
|
payload.get("current_recruiter_id"),EnumRoles.RECRUITER,"current_recruiter_id",
|
||||||
|
)
|
||||||
|
fields["current_recruiter_id"]=rec.id
|
||||||
|
|
||||||
row=await JobPosts.insert_job_post(self.session,fields)
|
row=await JobPosts.insert_job_post(self.session,fields)
|
||||||
|
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||||
|
await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by)
|
||||||
|
if rec:
|
||||||
|
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
|
||||||
|
|
||||||
if not publish:
|
if not publish:
|
||||||
return serialize_job_post(row)
|
return serialize_job_post(row)
|
||||||
|
|
@ -186,21 +209,42 @@ 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,top=None,skip=0,active_only=True):
|
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True):
|
||||||
|
hm_uid=None
|
||||||
|
if hiring_manager_id:
|
||||||
|
hm_uid=JobPosts._as_uuid(hiring_manager_id)
|
||||||
|
if hm_uid is None:
|
||||||
|
raise HTTPException(status_code=422,detail="hiring_manager_id must be a UUID")
|
||||||
rows,total=await JobPosts.fetch_job_posts(
|
rows,total=await JobPosts.fetch_job_posts(
|
||||||
self.session,search=search,top=top,skip=skip,active_only=active_only,
|
self.session,search=search,top=top,skip=skip,active_only=active_only,
|
||||||
department=department,requisition_status=requisition_status,
|
department=department,requisition_status=requisition_status,
|
||||||
employment_type=employment_type,
|
employment_type=employment_type,hiring_manager_id=hm_uid,
|
||||||
)
|
)
|
||||||
names=await Users.names_by_ids(
|
names=await Users.names_by_ids(
|
||||||
self.session,[r.current_recruiter_id for r in rows],
|
self.session,
|
||||||
|
[r.current_recruiter_id for r in rows]+[r.hiring_manager_id for r in rows],
|
||||||
)
|
)
|
||||||
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
|
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
|
||||||
return [
|
return [
|
||||||
serialize_job_row(
|
serialize_job_row(
|
||||||
r,
|
r,
|
||||||
recruiter_name=names.get(str(r.current_recruiter_id)),
|
recruiter_name=names.get(str(r.current_recruiter_id)),
|
||||||
|
hiring_manager_name=names.get(str(r.hiring_manager_id)),
|
||||||
applicant_count=counts.get(str(r.id),0),
|
applicant_count=counts.get(str(r.id),0),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
|
|
@ -208,13 +252,21 @@ class JobPost:
|
||||||
|
|
||||||
async def _job_row(self,row):
|
async def _job_row(self,row):
|
||||||
names=await Users.names_by_ids(
|
names=await Users.names_by_ids(
|
||||||
self.session,[row.current_recruiter_id] if row.current_recruiter_id else [],
|
self.session,
|
||||||
|
[row.current_recruiter_id,row.hiring_manager_id],
|
||||||
|
)
|
||||||
|
return serialize_job_row(
|
||||||
|
row,
|
||||||
|
recruiter_name=names.get(str(row.current_recruiter_id)),
|
||||||
|
hiring_manager_name=names.get(str(row.hiring_manager_id)),
|
||||||
)
|
)
|
||||||
return serialize_job_row(row,recruiter_name=names.get(str(row.current_recruiter_id)))
|
|
||||||
|
|
||||||
async def update_job(self,job_post_id,payload,current_user):
|
async def update_job(self,job_post_id,payload,current_user):
|
||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||||
|
existing=await JobPosts.get_job_post_by_id(self.session,job_post_id)
|
||||||
|
if not existing or existing.is_deleted:
|
||||||
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
allowed=("title","department","location","employment_type","vacancies",
|
allowed=("title","department","location","employment_type","vacancies",
|
||||||
"salary","experience_min","experience_max","description")
|
"salary","experience_min","experience_max","description")
|
||||||
fields={k:payload[k] for k in allowed if k in payload}
|
fields={k:payload[k] for k in allowed if k in payload}
|
||||||
|
|
@ -229,11 +281,41 @@ class JobPost:
|
||||||
fields["salary"]=str(high)
|
fields["salary"]=str(high)
|
||||||
if "department" in fields and fields["department"] is None:
|
if "department" in fields and fields["department"] is None:
|
||||||
fields["department"]=""
|
fields["department"]=""
|
||||||
|
|
||||||
|
assignment=Assignment(self.session)
|
||||||
|
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||||
|
hm_changed=False
|
||||||
|
rec_changed=False
|
||||||
|
if "hiring_manager_id" in payload:
|
||||||
|
raw=payload.get("hiring_manager_id")
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=422,detail="hiring_manager_id is required")
|
||||||
|
hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id")
|
||||||
|
fields["hiring_manager_id"]=hm.id
|
||||||
|
hm_changed=str(existing.hiring_manager_id)!=str(hm.id)
|
||||||
|
if "current_recruiter_id" in payload:
|
||||||
|
raw=payload.get("current_recruiter_id")
|
||||||
|
if raw is None or raw=="":
|
||||||
|
fields["current_recruiter_id"]=None
|
||||||
|
rec_changed=existing.current_recruiter_id is not None
|
||||||
|
else:
|
||||||
|
rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_id")
|
||||||
|
fields["current_recruiter_id"]=rec.id
|
||||||
|
rec_changed=str(existing.current_recruiter_id)!=str(rec.id)
|
||||||
|
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400,detail="No fields to update")
|
raise HTTPException(status_code=400,detail="No fields to update")
|
||||||
row=await JobPosts.update_job_post(self.session,job_post_id,fields)
|
row=await JobPosts.update_job_post(self.session,job_post_id,fields)
|
||||||
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 hm_changed:
|
||||||
|
await assignment.record_job_owner(
|
||||||
|
job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by,
|
||||||
|
)
|
||||||
|
if rec_changed:
|
||||||
|
await assignment.record_job_owner(
|
||||||
|
job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by,
|
||||||
|
)
|
||||||
return await self._job_row(row)
|
return await self._job_row(row)
|
||||||
|
|
||||||
async def delete_job(self,job_post_id,current_user):
|
async def delete_job(self,job_post_id,current_user):
|
||||||
|
|
@ -285,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)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from job.candidate.models import Notes
|
from job.candidate.models import Notes
|
||||||
from job.history.enums import HistoryEvent
|
from job.history.enums import HistoryEvent
|
||||||
|
|
@ -14,13 +12,7 @@ class Note:
|
||||||
self.session=session
|
self.session=session
|
||||||
|
|
||||||
async def _load(self,record_id):
|
async def _load(self,record_id):
|
||||||
uid=Notes._as_uuid(record_id)
|
return await Notes.get_note_by_id(self.session,record_id)
|
||||||
if uid is None:
|
|
||||||
return None
|
|
||||||
result=await self.session.execute(
|
|
||||||
select(Notes).options(selectinload(Notes.author)).where(Notes.id==uid)
|
|
||||||
)
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
async def get_note(self,note_id=None,user_id=None):
|
async def get_note(self,note_id=None,user_id=None):
|
||||||
if note_id:
|
if note_id:
|
||||||
|
|
@ -33,13 +25,8 @@ class Note:
|
||||||
uid=Notes._as_uuid(user_id)
|
uid=Notes._as_uuid(user_id)
|
||||||
if uid is None:
|
if uid is None:
|
||||||
raise HTTPException(status_code=400,detail="Invalid user_id")
|
raise HTTPException(status_code=400,detail="Invalid user_id")
|
||||||
result=await self.session.execute(
|
rows=await Notes.get_notes_by_user(self.session,uid)
|
||||||
select(Notes)
|
return [serialize_note(r) for r in rows]
|
||||||
.options(selectinload(Notes.author))
|
|
||||||
.where(Notes.user_id==uid)
|
|
||||||
.order_by(Notes.created_at.desc())
|
|
||||||
)
|
|
||||||
return [serialize_note(r) for r in result.scalars().all()]
|
|
||||||
|
|
||||||
async def create_note(self,payload,current_user):
|
async def create_note(self,payload,current_user):
|
||||||
fields={
|
fields={
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- 015_job_post_hiring_manager.sql
|
||||||
|
-- Stable owner of a requisition. Distinct from current_recruiter_id (who is
|
||||||
|
-- working the req now, and may change). Both people also get a job_assignments
|
||||||
|
-- history row; this column is the current pointer used by Jobs lists and the
|
||||||
|
-- Managers portal. Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
-- Needed because prod boots with DB_AUTOGENERATE=false.
|
||||||
|
|
||||||
|
ALTER TABLE app.job_posts
|
||||||
|
ADD COLUMN IF NOT EXISTS hiring_manager_id UUID REFERENCES app.users(id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_job_posts_hiring_manager_id
|
||||||
|
ON app.job_posts (hiring_manager_id);
|
||||||
|
|
@ -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)"},
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,16 @@ class Roles(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_names(cls, session: AsyncSession, names):
|
||||||
|
keys = [n for n in (names or []) if n]
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls).where(cls.role_name.in_(keys), cls.is_deleted == False) # noqa: E712
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_roles(cls, session: AsyncSession, search: str | None):
|
async def count_roles(cls, session: AsyncSession, search: str | None):
|
||||||
statement = (
|
statement = (
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@ import uuid
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from role.models import Roles
|
from role.models import Roles
|
||||||
from tasks.models import Tasks
|
from tasks.models import Tasks
|
||||||
|
|
@ -55,10 +53,7 @@ class Task:
|
||||||
ids = [i for i in set(ids) if i]
|
ids = [i for i in set(ids) if i]
|
||||||
if not ids:
|
if not ids:
|
||||||
return {}
|
return {}
|
||||||
result = await self.session.execute(
|
return {u.id: u for u in await Users.get_by_ids(self.session, ids)}
|
||||||
select(Users).options(selectinload(Users.role)).where(Users.id.in_(ids))
|
|
||||||
)
|
|
||||||
return {u.id: u for u in result.scalars().all()}
|
|
||||||
|
|
||||||
async def _validate_assignee(self, assignee_id):
|
async def _validate_assignee(self, assignee_id):
|
||||||
"""Assignees must be recruiter-role accounts (role resolved from the DB):
|
"""Assignees must be recruiter-role accounts (role resolved from the DB):
|
||||||
|
|
@ -78,10 +73,8 @@ class Task:
|
||||||
"""Creation is limited to system admin / HR admin / recruiter. The role
|
"""Creation is limited to system admin / HR admin / recruiter. The role
|
||||||
ids are looked up from the roles table, and the permission-tag guard on
|
ids are looked up from the roles table, and the permission-tag guard on
|
||||||
the route (tasks.create) still applies on top of this."""
|
the route (tasks.create) still applies on top of this."""
|
||||||
result = await self.session.execute(
|
rows = await Roles.get_by_names(self.session, CREATOR_ROLES)
|
||||||
select(Roles).where(Roles.role_name.in_(CREATOR_ROLES))
|
allowed_ids = {r.id for r in rows}
|
||||||
)
|
|
||||||
allowed_ids = {r.id for r in result.scalars().all()}
|
|
||||||
if current_user.get("role_id") not in allowed_ids:
|
if current_user.get("role_id") not in allowed_ids:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,12 @@ async def delete_user(
|
||||||
@router.get("/managers/fetch")
|
@router.get("/managers/fetch")
|
||||||
async def fetch_managers(
|
async def fetch_managers(
|
||||||
current_user: dict = Depends(
|
current_user: dict = Depends(
|
||||||
require_permission(PermissionTag.JOBS_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False)
|
require_permission(
|
||||||
|
PermissionTag.JOBS_VIEW,
|
||||||
|
PermissionTag.CANDIDATES_VIEW,
|
||||||
|
PermissionTag.JOB_BOARD_CREATE,
|
||||||
|
require_all=False,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ class Users(SQLModel, table=True):
|
||||||
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
|
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
|
||||||
# user row once per post. Without an explicit strategy the default is a lazy load,
|
# user row once per post. Without an explicit strategy the default is a lazy load,
|
||||||
# which raises MissingGreenlet the moment anything touches it under asyncio.
|
# which raises MissingGreenlet the moment anything touches it under asyncio.
|
||||||
# foreign_keys must match the other side: job_posts.current_recruiter_id is a
|
# foreign_keys must match the other side: job_posts also has current_recruiter_id
|
||||||
# second FK into this table, so this relation has to say it means created_by.
|
# and hiring_manager_id into this table, so this relation has to say created_by.
|
||||||
job_posts: List[JobPosts] = Relationship(
|
job_posts: List[JobPosts] = Relationship(
|
||||||
back_populates="user",
|
back_populates="user",
|
||||||
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
|
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -133,6 +150,21 @@ class Users(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return {str(uid): name for uid, name in result.all()}
|
return {str(uid): name for uid, name in result.all()}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_ids(cls, session: AsyncSession, ids):
|
||||||
|
"""Users with role selectin-loaded. UUID keys so callers can map by row.assignee_id."""
|
||||||
|
uids = []
|
||||||
|
for raw in ids or []:
|
||||||
|
uid = cls._as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
uids.append(uid)
|
||||||
|
if not uids:
|
||||||
|
return []
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls).options(selectinload(cls.role)).where(cls.id.in_(uids))
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_user_by_id(cls, session: AsyncSession, record_id: str):
|
async def get_user_by_id(cls, session: AsyncSession, record_id: str):
|
||||||
uid = cls._as_uuid(record_id)
|
uid = cls._as_uuid(record_id)
|
||||||
|
|
|
||||||
|
|
@ -114,13 +114,13 @@ class User:
|
||||||
|
|
||||||
async def get_managers(self):
|
async def get_managers(self):
|
||||||
"""Hiring-manager directory for Jobs/Candidates callers who do not hold rbac_users.view."""
|
"""Hiring-manager directory for Jobs/Candidates callers who do not hold rbac_users.view."""
|
||||||
from job.assignment.models import JobAssignments
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
role=await Roles.get_role_by_name(self.session,EnumRoles.HIRING_MANAGER.value)
|
role=await Roles.get_role_by_name(self.session,EnumRoles.HIRING_MANAGER.value)
|
||||||
if role is None:
|
if role is None:
|
||||||
raise HTTPException(status_code=500,detail="Role hiring_manager is not seeded")
|
raise HTTPException(status_code=500,detail="Role hiring_manager is not seeded")
|
||||||
rows=await Users.get_users(self.session,top=500,role_id=role.id)
|
rows=await Users.get_users(self.session,top=500,role_id=role.id)
|
||||||
counts=await JobAssignments.count_open_reqs_by_users(self.session,[u.id for u in rows])
|
counts=await JobPosts.count_open_reqs_by_hiring_managers(self.session,[u.id for u in rows])
|
||||||
data=[
|
data=[
|
||||||
{
|
{
|
||||||
"id": str(u.id),
|
"id": str(u.id),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { request } from '../lib/apiClient'
|
import { request } from '../lib/apiClient'
|
||||||
|
import { toStageCounts } from './pipeline'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard analytics aggregates — backend/analytics/app.py.
|
* Dashboard analytics aggregates — backend/analytics/app.py.
|
||||||
|
|
@ -27,6 +28,28 @@ export function funnel({ fromDate, toDate, department, recruiterId } = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Board column order used by the pipeline page. Rejected is last so callers
|
||||||
|
* that drop outcomes can slice it off without re-sorting. */
|
||||||
|
const BOARD_STAGE_ORDER = [
|
||||||
|
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||||
|
'Approved', 'Hired', 'On Hold', 'Rejected',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold /analytics/funnel/fetch rows (11 enum statuses) onto the pipeline
|
||||||
|
* columns. Same mapping the board uses, so CLOSED is Rejected and ONHOLD /
|
||||||
|
* APPROVED keep their own columns rather than folding into Screening / Hired.
|
||||||
|
*/
|
||||||
|
export function toBoardStageRows(funnelRows, { includeRejected = false } = {}) {
|
||||||
|
const folded = toStageCounts(
|
||||||
|
Object.fromEntries((funnelRows || []).map((r) => [r.stage, r.count || 0])),
|
||||||
|
)
|
||||||
|
const order = includeRejected
|
||||||
|
? BOARD_STAGE_ORDER
|
||||||
|
: BOARD_STAGE_ORDER.filter((s) => s !== 'Rejected')
|
||||||
|
return order.map((stage) => ({ stage, count: folded[stage] || 0 }))
|
||||||
|
}
|
||||||
|
|
||||||
export function hiringTrend({ months = 7, fromDate, toDate, department, recruiterId } = {}) {
|
export function hiringTrend({ months = 7, fromDate, toDate, department, recruiterId } = {}) {
|
||||||
return request('/analytics/hiring-trend/fetch', {
|
return request('/analytics/hiring-trend/fetch', {
|
||||||
params: {
|
params: {
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,28 @@ import { request } from '../lib/apiClient'
|
||||||
assignments.js — who owns a requisition, and who owns an application.
|
assignments.js — who owns a requisition, and who owns an application.
|
||||||
|
|
||||||
Two parallel tables behind four routes (backend/job/app.py):
|
Two parallel tables behind four routes (backend/job/app.py):
|
||||||
job_assignments — a recruiter on a JOB POST (jobs.view / jobs.edit)
|
job_assignments — recruiter OR hiring manager on a JOB POST
|
||||||
application_assignments — a recruiter on ONE APPLICATION (candidates.view / candidates.edit)
|
application_assignments — a recruiter on ONE APPLICATION
|
||||||
|
|
||||||
Rows are valid-time intervals: `valid_to === null` is the assignment in force
|
Rows are valid-time intervals: `valid_to === null` is the assignment in force
|
||||||
now, and the fetch routes return only those by default. There is no unassign
|
now. Fetch defaults to current-only; pass currentOnly: false for the history
|
||||||
or reassign route — `insert_assignment` closes the previous open interval and
|
log. Reassignment closes the previous open interval of the SAME role.
|
||||||
opens a new one, so assigning someone else IS the reassignment.
|
|
||||||
|
|
||||||
The server rejects any user whose role is not `recruiter` with a 422
|
Recruiter pickers use /tasks/assignees/fetch; hiring-manager pickers use
|
||||||
(Assignment._require_recruiter), which is why every picker here is sourced
|
/managers/fetch. Neither needs rbac_users.view. The current pointers also
|
||||||
from /tasks/assignees/fetch — the one endpoint that already returns exactly
|
live on job_posts (current_recruiter_id, hiring_manager_id) and PATCH
|
||||||
the active recruiter-role users, and needs no rbac_users.view to call.
|
/jobs/update is the Jobs-screen write path.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
/** Current recruiter(s) on one requisition. */
|
/** Current or historical owners of one requisition. */
|
||||||
export function listJob(jobPostId) {
|
export function listJob(jobPostId, { currentOnly, assignmentRole } = {}) {
|
||||||
return request('/job/assignments/fetch', { params: { job_post_id: jobPostId } })
|
return request('/job/assignments/fetch', {
|
||||||
|
params: {
|
||||||
|
job_post_id: jobPostId,
|
||||||
|
current_only: currentOnly,
|
||||||
|
assignment_role: assignmentRole,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assign a recruiter to a requisition. Supersedes whoever held it. */
|
/** Assign a recruiter to a requisition. Supersedes whoever held it. */
|
||||||
|
|
@ -60,7 +65,8 @@ export function toAssignmentView(row, namesById) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
name: namesById?.get(String(row.user_id)) ?? null,
|
name: row.user_name || namesById?.get(String(row.user_id)) || null,
|
||||||
|
assignedByName: row.assigned_by_name ?? null,
|
||||||
role: row.assignment_role || 'primary_recruiter',
|
role: row.assignment_role || 'primary_recruiter',
|
||||||
jobPostId: row.job_post_id ?? null,
|
jobPostId: row.job_post_id ?? null,
|
||||||
inboxId: row.inbox_id ?? null,
|
inboxId: row.inbox_id ?? null,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
|
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
|
||||||
|
import { STATUS_FROM_STAGE } from './pipeline'
|
||||||
|
|
||||||
/** Active job posts for pickers. Needs job_board.view OR candidates.view.
|
/** Active job posts for pickers. Needs job_board.view OR candidates.view.
|
||||||
*
|
*
|
||||||
|
|
@ -92,6 +93,34 @@ export function viewCvBankCv(id) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job Matching queue — CV Import "No job" rows (`apply_via=cv_bank`).
|
||||||
|
* Needs candidates.view. `assigned` is tri-valued: omit for all, false for
|
||||||
|
* still in the bank, true for rows that already have a job_post_id.
|
||||||
|
*/
|
||||||
|
export function listMatching({ search, top = 10, skip = 0, assigned } = {}) {
|
||||||
|
return request('/candidate/matching/fetch', {
|
||||||
|
params: { search, top, skip, assigned },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One matching row by manual_upload_candidate id. Needs candidates.view. */
|
||||||
|
export function getMatching(id) {
|
||||||
|
return request('/candidate/matching/fetch_by_id', { params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link (or unlink) a job post on a CV-bank row. Needs candidates.edit.
|
||||||
|
* After assign the row has job_post_id + user_id and Pipeline/Candidates
|
||||||
|
* fetch it like any other manual_upload_candidate.
|
||||||
|
*/
|
||||||
|
export function assignMatchingJob(id, jobPostId) {
|
||||||
|
return request('/candidate/matching/assign', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { id, job_post_id: jobPostId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Score the decoded attachments of inbox messages against a job post.
|
* Score the decoded attachments of inbox messages against a job post.
|
||||||
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
|
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
|
||||||
|
|
@ -290,7 +319,7 @@ export function createManual({
|
||||||
put('current_position', currentPosition)
|
put('current_position', currentPosition)
|
||||||
put('platform', source)
|
put('platform', source)
|
||||||
put('experience', experience)
|
put('experience', experience)
|
||||||
put('status', stage)
|
put('status', STATUS_FROM_STAGE[stage] || stage)
|
||||||
put('referral_by', referralBy)
|
put('referral_by', referralBy)
|
||||||
return request('/candidate/create/candidate', { method: 'POST', body: form })
|
return request('/candidate/create/candidate', { method: 'POST', body: form })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export function listMessages() {
|
||||||
* `assigned` is tri-valued: omit for no filter, true for rows with an
|
* `assigned` is tri-valued: omit for no filter, true for rows with an
|
||||||
* assigned_job_post_id, false for the Job Matching queue.
|
* assigned_job_post_id, false for the Job Matching queue.
|
||||||
*/
|
*/
|
||||||
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions } = {}) {
|
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState } = {}) {
|
||||||
return request('/inbox/all-applications', {
|
return request('/inbox/all-applications', {
|
||||||
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
|
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
|
||||||
// to true = no filter), send false for the Unread tab only. buildUrl drops
|
// to true = no filter), send false for the Unread tab only. buildUrl drops
|
||||||
|
|
@ -30,6 +30,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
|
||||||
// Same for `is_duplicate`: omit unless the Duplicates tab.
|
// Same for `is_duplicate`: omit unless the Duplicates tab.
|
||||||
// `no_suggestions`: Job Matching "No suggestions" tab — unassigned + empty
|
// `no_suggestions`: Job Matching "No suggestions" tab — unassigned + empty
|
||||||
// suggested_job_post_ids. Omit unless that tab.
|
// suggested_job_post_ids. Omit unless that tab.
|
||||||
|
// `processing_state`: Processed / Rejected tabs (Move to Shortlist writes
|
||||||
|
// processed, not application_status PROCESS).
|
||||||
params: {
|
params: {
|
||||||
search,
|
search,
|
||||||
top,
|
top,
|
||||||
|
|
@ -40,6 +42,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
|
||||||
assigned,
|
assigned,
|
||||||
is_duplicate: isDuplicate,
|
is_duplicate: isDuplicate,
|
||||||
no_suggestions: noSuggestions,
|
no_suggestions: noSuggestions,
|
||||||
|
processing_state: processingState,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +125,7 @@ export function bulkSetRead(recordIds, read) {
|
||||||
* Resolves to `{updated, read}`, where `updated` counts rows that actually
|
* Resolves to `{updated, read}`, where `updated` counts rows that actually
|
||||||
* CHANGED state, so it is safe to show in a toast.
|
* CHANGED state, so it is safe to show in a toast.
|
||||||
*/
|
*/
|
||||||
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate } = {}) {
|
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, processingState } = {}) {
|
||||||
return request('/inbox/read-all', {
|
return request('/inbox/read-all', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -132,6 +135,7 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned,
|
||||||
application_status: applicationStatus,
|
application_status: applicationStatus,
|
||||||
assigned,
|
assigned,
|
||||||
is_duplicate: isDuplicate,
|
is_duplicate: isDuplicate,
|
||||||
|
processing_state: processingState,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,14 @@ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
|
||||||
* picker payload does not.
|
* picker payload does not.
|
||||||
*/
|
*/
|
||||||
export function list({ search, department, requisitionStatus, employmentType,
|
export function list({ search, department, requisitionStatus, employmentType,
|
||||||
top, skip, activeOnly } = {}) {
|
hiringManagerId, top, skip, activeOnly } = {}) {
|
||||||
return request('/jobs/fetch', {
|
return request('/jobs/fetch', {
|
||||||
params: {
|
params: {
|
||||||
search,
|
search,
|
||||||
department,
|
department,
|
||||||
requisition_status: requisitionStatus,
|
requisition_status: requisitionStatus,
|
||||||
employment_type: employmentType,
|
employment_type: employmentType,
|
||||||
|
hiring_manager_id: hiringManagerId,
|
||||||
top,
|
top,
|
||||||
skip,
|
skip,
|
||||||
active_only: activeOnly,
|
active_only: activeOnly,
|
||||||
|
|
@ -24,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
|
||||||
|
|
@ -48,6 +62,8 @@ export function toJobView(row) {
|
||||||
publishStatus: row.status,
|
publishStatus: row.status,
|
||||||
recruiter: row.recruiter_name,
|
recruiter: row.recruiter_name,
|
||||||
recruiterId: row.current_recruiter_id,
|
recruiterId: row.current_recruiter_id,
|
||||||
|
hiringManager: row.hiring_manager_name,
|
||||||
|
hiringManagerId: row.hiring_manager_id,
|
||||||
createdByName: row.created_by_name,
|
createdByName: row.created_by_name,
|
||||||
applicantCount: row.applicant_count ?? 0,
|
applicantCount: row.applicant_count ?? 0,
|
||||||
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
|
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
|
||||||
|
|
@ -63,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.
|
||||||
|
|
@ -118,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 } })
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,40 +15,43 @@ import { request } from '../lib/apiClient'
|
||||||
/**
|
/**
|
||||||
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
||||||
*
|
*
|
||||||
* The enum has 11 values and the board 7 columns, so this is deliberately
|
* The enum has 11 values. Approved and On Hold are first-class columns (they
|
||||||
* many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere)
|
* used to be folded into Hired / Screening). CLOSED is the inbox default for
|
||||||
* and reads as Shortlist rather than as an outcome, ONHOLD parks in Screening, and
|
* an application that did not progress — it reads as Rejected, same as the
|
||||||
* APPROVED is the pre-HIRED spelling of a hire.
|
* board's Rejected column, not as Shortlist.
|
||||||
*
|
*
|
||||||
* Anything unmapped falls through to Shortlist rather than vanishing from the
|
* Anything unmapped falls through to Shortlist rather than vanishing from the
|
||||||
* board — a card with no column is a candidate nobody sees.
|
* board — a card with no column is a candidate nobody sees.
|
||||||
*/
|
*/
|
||||||
export const STAGE_FROM_STATUS = {
|
export const STAGE_FROM_STATUS = {
|
||||||
PENDING: 'Shortlist',
|
PENDING: 'Shortlist',
|
||||||
CLOSED: 'Shortlist',
|
|
||||||
PROCESS: 'Screening',
|
PROCESS: 'Screening',
|
||||||
ONHOLD: 'Screening',
|
|
||||||
SCREENING: 'Screening',
|
SCREENING: 'Screening',
|
||||||
|
ONHOLD: 'On Hold',
|
||||||
ASSESSMENT: 'Assessment',
|
ASSESSMENT: 'Assessment',
|
||||||
INTERVIEW: 'Interview',
|
INTERVIEW: 'Interview',
|
||||||
OFFER: 'Offer',
|
OFFER: 'Offer',
|
||||||
|
APPROVED: 'Approved',
|
||||||
HIRED: 'Hired',
|
HIRED: 'Hired',
|
||||||
APPROVED: 'Hired',
|
CLOSED: 'Rejected',
|
||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Column -> the status WRITTEN on a drop. Not the inverse of the map above: the
|
* Column -> the status WRITTEN on a drop. Not the inverse of the map above:
|
||||||
* legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are
|
* PROCESS is readable as Screening but is never written, so the vocabulary
|
||||||
* never written, so the vocabulary converges on the canonical value as cards get
|
* converges on the canonical value as cards get moved. Shortlist writes
|
||||||
* moved. Shortlist writes PENDING because the enum has no SHORTLIST member.
|
* PENDING because the enum has no SHORTLIST member. Rejected writes REJECTED
|
||||||
|
* (not CLOSED) so new drops are distinguishable from the inbox default.
|
||||||
*/
|
*/
|
||||||
export const STATUS_FROM_STAGE = {
|
export const STATUS_FROM_STAGE = {
|
||||||
Shortlist: 'PENDING',
|
Shortlist: 'PENDING',
|
||||||
Screening: 'SCREENING',
|
Screening: 'SCREENING',
|
||||||
|
'On Hold': 'ONHOLD',
|
||||||
Assessment: 'ASSESSMENT',
|
Assessment: 'ASSESSMENT',
|
||||||
Interview: 'INTERVIEW',
|
Interview: 'INTERVIEW',
|
||||||
Offer: 'OFFER',
|
Offer: 'OFFER',
|
||||||
|
Approved: 'APPROVED',
|
||||||
Hired: 'HIRED',
|
Hired: 'HIRED',
|
||||||
Rejected: 'REJECTED',
|
Rejected: 'REJECTED',
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +87,7 @@ export function listApplications({ jobId, limit, offset } = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN)
|
* Fold the 11 status counts into the board columns. Unmapped keys (UNKNOWN)
|
||||||
* land in Shortlist, same as STAGE_FROM_STATUS's card fallback.
|
* land in Shortlist, same as STAGE_FROM_STATUS's card fallback.
|
||||||
*/
|
*/
|
||||||
export function toStageCounts(byStatus) {
|
export function toStageCounts(byStatus) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ export function list({ record_id, search, top, skip, roleId } = {}) {
|
||||||
return request('/users/fetch', { params: { record_id, search, top, skip, role_id: roleId } })
|
return request('/users/fetch', { params: { record_id, search, top, skip, role_id: roleId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listManagers() {
|
||||||
|
return request('/managers/fetch')
|
||||||
|
}
|
||||||
|
|
||||||
export function listPendingApprovals() {
|
export function listPendingApprovals() {
|
||||||
return request('/users/pending-approvals')
|
return request('/users/pending-approvals')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const ROUTES = [
|
||||||
// --- Workspace ---
|
// --- Workspace ---
|
||||||
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
|
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
|
||||||
{ path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' },
|
{ path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' },
|
||||||
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'inbox.view', badge: 'matching' },
|
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' },
|
||||||
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
|
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
|
||||||
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
|
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
|
||||||
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },
|
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import * as inboxApi from '../api/inbox'
|
import * as inboxApi from '../api/inbox'
|
||||||
|
import * as candidatesApi from '../api/candidates'
|
||||||
import * as tasksApi from '../api/tasks'
|
import * as tasksApi from '../api/tasks'
|
||||||
import * as jobsApi from '../api/jobs'
|
import * as jobsApi from '../api/jobs'
|
||||||
import * as notificationsApi from '../api/notifications'
|
import * as notificationsApi from '../api/notifications'
|
||||||
|
|
@ -94,15 +95,19 @@ export function useHotkeys({ onEscape }) {
|
||||||
* that every mutating call site had to remember to call; these are derived, so
|
* that every mutating call site had to remember to call; these are derived, so
|
||||||
* completing a task updates the badge with no call site involved at all.
|
* completing a task updates the badge with no call site involved at all.
|
||||||
*
|
*
|
||||||
* `matching` is the unassigned applications queue — the one number Job Matching
|
* `matching` is the unassigned CV-bank queue — CVs imported with No job that
|
||||||
* exists to drive to zero.
|
* still have no job_post_id. Job Matching exists to drive that number to zero.
|
||||||
*/
|
*/
|
||||||
export function useBadges() {
|
export function useBadges() {
|
||||||
const { data: matchingTotal = 0 } = useQuery({
|
const { data: matchingTotal = 0 } = useQuery({
|
||||||
queryKey: qk.mailbox.assignments({ assigned: false }),
|
queryKey: qk.candidates.matching({ assigned: false, count: true }),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await inboxApi.listApplications({ assigned: false, top: 1 })
|
try {
|
||||||
|
const res = await candidatesApi.listMatching({ assigned: false, top: 1 })
|
||||||
return res?.total ?? 0
|
return res?.total ?? 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { data: tasksTotal = 0 } = useQuery({
|
const { data: tasksTotal = 0 } = useQuery({
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export const TODAY = new Date('2026-07-09T09:00:00');
|
||||||
const grades = ['L2', 'L3', 'L4', 'L5', 'L6', 'L7'];
|
const grades = ['L2', 'L3', 'L4', 'L5', 'L6', 'L7'];
|
||||||
const jobStatuses = ['Open', 'On Hold', 'Closed', 'Draft'];
|
const jobStatuses = ['Open', 'On Hold', 'Closed', 'Draft'];
|
||||||
const educationLevels = ["Bachelor's Degree", "Master's Degree", "PhD", "Associate Degree", "High School"];
|
const educationLevels = ["Bachelor's Degree", "Master's Degree", "PhD", "Associate Degree", "High School"];
|
||||||
const stages = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected'];
|
const stages = ['Shortlist', 'Screening', 'On Hold', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'Rejected'];
|
||||||
const sources = ['LinkedIn', 'Company Site', 'Referral', 'Indeed', 'Job Fair', 'Agency', 'GitHub', 'AngelList'];
|
const sources = ['LinkedIn', 'Company Site', 'Referral', 'Indeed', 'Job Fair', 'Agency', 'GitHub', 'AngelList'];
|
||||||
const companies = ['Stripe', 'Airbnb', 'Datadog', 'Notion', 'Figma', 'Shopify', 'Snowflake', 'Twilio', 'Coinbase', 'Atlassian', 'Asana', 'Ramp', 'Brex', 'Vercel', 'Retool', 'Amplitude', 'Segment', 'MongoDB', 'HashiCorp', 'Cloudflare'];
|
const companies = ['Stripe', 'Airbnb', 'Datadog', 'Notion', 'Figma', 'Shopify', 'Snowflake', 'Twilio', 'Coinbase', 'Atlassian', 'Asana', 'Ramp', 'Brex', 'Vercel', 'Retool', 'Amplitude', 'Segment', 'MongoDB', 'HashiCorp', 'Cloudflare'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,7 @@ function css(name) { return getComputedStyle(document.documentElement).getProper
|
||||||
let a0 = -Math.PI / 2;
|
let a0 = -Math.PI / 2;
|
||||||
segs.length = 0;
|
segs.length = 0;
|
||||||
data.forEach((v, i) => {
|
data.forEach((v, i) => {
|
||||||
|
if (!v) return
|
||||||
const a1 = a0 + (v / total) * Math.PI * 2 * prog;
|
const a1 = a0 + (v / total) * Math.PI * 2 * prog;
|
||||||
ctx.beginPath(); ctx.moveTo(cx, cy);
|
ctx.beginPath(); ctx.moveTo(cx, cy);
|
||||||
ctx.arc(cx, cy, r, a0, a1); ctx.closePath();
|
ctx.arc(cx, cy, r, a0, a1); ctx.closePath();
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export const qk = {
|
||||||
managers: {
|
managers: {
|
||||||
all: () => ['managers'],
|
all: () => ['managers'],
|
||||||
list: (p = {}) => ['managers', 'list', p],
|
list: (p = {}) => ['managers', 'list', p],
|
||||||
|
directory: () => ['managers', 'directory'],
|
||||||
},
|
},
|
||||||
orgSettings: {
|
orgSettings: {
|
||||||
all: () => ['orgSettings'],
|
all: () => ['orgSettings'],
|
||||||
|
|
@ -78,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'],
|
||||||
|
|
@ -92,6 +95,8 @@ export const qk = {
|
||||||
count: (p = {}) => ['candidates', 'count', p],
|
count: (p = {}) => ['candidates', 'count', p],
|
||||||
detail: (id) => ['candidates', 'detail', id],
|
detail: (id) => ['candidates', 'detail', id],
|
||||||
history: (id, p = {}) => ['candidates', 'history', id, p],
|
history: (id, p = {}) => ['candidates', 'history', id, p],
|
||||||
|
matching: (p = {}) => ['candidates', 'matching', p],
|
||||||
|
matchingDetail: (id) => ['candidates', 'matching', 'detail', id],
|
||||||
},
|
},
|
||||||
// Board rows come from the same endpoint as qk.candidates.list but are cached
|
// Board rows come from the same endpoint as qk.candidates.list but are cached
|
||||||
// MAPPED (kanban cards, not the raw envelope), so they need their own key —
|
// MAPPED (kanban cards, not the raw envelope), so they need their own key —
|
||||||
|
|
|
||||||
|
|
@ -342,10 +342,10 @@ export default function Analytics() {
|
||||||
}
|
}
|
||||||
}, [offersQuery.data])
|
}, [offersQuery.data])
|
||||||
|
|
||||||
/* The funnel has 11 statuses; REJECTED is dropped because it is an outcome,
|
/* Fold the 11 enum statuses onto the 7 pipeline columns; REJECTED is dropped
|
||||||
not a stage, and its volume flattens every other bar. */
|
because it is an outcome, not a stage, and its volume flattens every other bar. */
|
||||||
const pipeline = useMemo(() => {
|
const pipeline = useMemo(() => {
|
||||||
const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED')
|
const rows = analyticsApi.toBoardStageRows(funnelQuery.data ?? [])
|
||||||
return {
|
return {
|
||||||
labels: rows.map((p) => p.stage),
|
labels: rows.map((p) => p.stage),
|
||||||
data: rows.map((p) => p.count),
|
data: rows.map((p) => p.count),
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import { companies, fmtDate, moneyK, pick } from '../data/seed'
|
||||||
Forms, Feedback) → track (Notes, Activity) → audit (Timeline, History). */
|
Forms, Feedback) → track (Notes, Activity) → audit (Timeline, History). */
|
||||||
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History']
|
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History']
|
||||||
// Forward progression for the live Advance button. Rejected has no next stage.
|
// Forward progression for the live Advance button. Rejected has no next stage.
|
||||||
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||||
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
|
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
|
||||||
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
||||||
|
|
@ -213,6 +213,7 @@ export default function CandidateProfile({
|
||||||
onDone: () => {
|
onDone: () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||||
qc.invalidateQueries({ queryKey: qk.forms.all() })
|
qc.invalidateQueries({ queryKey: qk.forms.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -281,7 +282,7 @@ export default function CandidateProfile({
|
||||||
{advanceLive.isPending
|
{advanceLive.isPending
|
||||||
? 'Moving…'
|
? 'Moving…'
|
||||||
: nextStage ? `Advance to ${nextStage}`
|
: nextStage ? `Advance to ${nextStage}`
|
||||||
: stageLabel === 'Rejected' ? 'Rejected' : 'Pipeline complete'}
|
: stageLabel === 'Rejected' || stageLabel === 'On Hold' ? stageLabel : 'Pipeline complete'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
|
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '.
|
||||||
const EMPTY_FILTERS = { account: '', department: '' }
|
const EMPTY_FILTERS = { account: '', department: '' }
|
||||||
|
|
||||||
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
|
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
|
||||||
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||||
|
|
||||||
/** Seeded `candidate` role is id 8; id 4 is hiring_manager (signup default). */
|
/** Seeded `candidate` role is id 8; id 4 is hiring_manager (signup default). */
|
||||||
const CANDIDATE_ROLE_ID = 8
|
const CANDIDATE_ROLE_ID = 8
|
||||||
|
|
|
||||||
|
|
@ -349,9 +349,8 @@ export default function CvImport() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The stored-CV bank — a private store with no job, account or inbox entry.
|
/* The stored-CV bank — CVs imported with No job. Unassigned rows live here;
|
||||||
This list is the bank's home: browse, download, or remove; picking a CV up
|
assigning a job in Job Matching sets job_post_id and they leave this list. */
|
||||||
for a job later is a future action. */
|
|
||||||
function CvBank() {
|
function CvBank() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
@ -432,6 +431,11 @@ function CvBank() {
|
||||||
{r.candidate_email || 'No email detected'}
|
{r.candidate_email || 'No email detected'}
|
||||||
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
|
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
{r.linkedin_url ? (
|
||||||
|
<div className="cell-sub" style={{ marginTop: 2 }}>
|
||||||
|
<a href={r.linkedin_url} target="_blank" rel="noopener noreferrer">{r.linkedin_url}</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{r.file_path ? (
|
{r.file_path ? (
|
||||||
<div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}>
|
<div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}>
|
||||||
{r.file_path}
|
{r.file_path}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,14 @@ import { fmtShort, money, initials as initialsOf, avatarColor } from '../data/se
|
||||||
import * as analyticsApi from '../api/analytics'
|
import * as analyticsApi from '../api/analytics'
|
||||||
import * as interviewsApi from '../api/interviews'
|
import * as interviewsApi from '../api/interviews'
|
||||||
import * as jobsApi from '../api/jobs'
|
import * as jobsApi from '../api/jobs'
|
||||||
|
import * as pipelineApi from '../api/pipeline'
|
||||||
import * as tasksApi from '../api/tasks'
|
import * as tasksApi from '../api/tasks'
|
||||||
|
|
||||||
const POLL_MS = 60_000
|
const POLL_MS = 60_000
|
||||||
|
const BOARD_ACTIVE = [
|
||||||
|
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||||
|
'Approved', 'Hired', 'On Hold', 'Rejected',
|
||||||
|
]
|
||||||
|
|
||||||
function asObject(data) {
|
function asObject(data) {
|
||||||
return data && typeof data === 'object' && !Array.isArray(data) ? data : null
|
return data && typeof data === 'object' && !Array.isArray(data) ? data : null
|
||||||
|
|
@ -56,17 +61,6 @@ function trendProps(cur, prior, { lowerIsBetter = false, fmt = pctDelta } = {})
|
||||||
return { trend: text, dir: good ? 'up' : 'down', arrow: went }
|
return { trend: text, dir: good ? 'up' : 'down', arrow: went }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Display order for pipeline stages: progression first, then held/terminal.
|
|
||||||
The API returns enum order, which interleaves them (PROCESS before PENDING,
|
|
||||||
CLOSED before SCREENING). */
|
|
||||||
const STAGE_ORDER = [
|
|
||||||
'PENDING', 'SCREENING', 'PROCESS', 'ASSESSMENT', 'INTERVIEW',
|
|
||||||
'OFFER', 'APPROVED', 'HIRED', 'ONHOLD', 'CLOSED',
|
|
||||||
]
|
|
||||||
const stageRank = (s) => {
|
|
||||||
const i = STAGE_ORDER.indexOf(s)
|
|
||||||
return i === -1 ? STAGE_ORDER.length : i
|
|
||||||
}
|
|
||||||
|
|
||||||
function greetingFor(now = new Date()) {
|
function greetingFor(now = new Date()) {
|
||||||
const h = now.getHours()
|
const h = now.getHours()
|
||||||
|
|
@ -200,9 +194,18 @@ export default function Dashboard() {
|
||||||
},
|
},
|
||||||
refetchInterval: POLL_MS,
|
refetchInterval: POLL_MS,
|
||||||
})
|
})
|
||||||
|
/* Same counts the /pipeline board paints — not /analytics/funnel. Funnel is a
|
||||||
|
second query with a 60s staleTime, so a drag to Screening left this widget
|
||||||
|
on the previous snapshot (Interview still filled the doughnut). The board
|
||||||
|
already invalidates qk.pipeline.all() on drop. staleTime 0 so navigating
|
||||||
|
back here never serves that snapshot as "fresh". */
|
||||||
const funnelQuery = useQuery({
|
const funnelQuery = useQuery({
|
||||||
queryKey: qk.analytics.funnel(),
|
queryKey: qk.pipeline.board({ scope: 'dashboard' }),
|
||||||
queryFn: async () => asList((await analyticsApi.funnel()).data),
|
queryFn: async () => {
|
||||||
|
const res = await pipelineApi.listApplications({ limit: 1 })
|
||||||
|
return pipelineApi.toStageCounts(res?.counts?.by_status)
|
||||||
|
},
|
||||||
|
staleTime: 0,
|
||||||
refetchInterval: POLL_MS,
|
refetchInterval: POLL_MS,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -278,21 +281,20 @@ export default function Dashboard() {
|
||||||
[trendQuery.data],
|
[trendQuery.data],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* "Active by stage" means exactly that: REJECTED is excluded (matching the
|
/* Occupied board columns only. Zero-count stages in the doughnut are a
|
||||||
Analytics screen's pipeline card), and each bar is that stage's share of
|
canvas footgun (arc(a,a) can paint a full circle and hide Screening). */
|
||||||
the ACTIVE total — the old base was the first row's count, which is the
|
|
||||||
PROCESS stage in enum order, so an empty PROCESS stage zeroed every bar
|
|
||||||
while the doughnut centre said candidates existed. */
|
|
||||||
const pipeRows = useMemo(() => {
|
const pipeRows = useMemo(() => {
|
||||||
const rows = asList(funnelQuery.data)
|
const folded = funnelQuery.data && typeof funnelQuery.data === 'object'
|
||||||
.filter((r) => r.stage !== 'REJECTED')
|
? funnelQuery.data
|
||||||
.sort((a, b) => stageRank(a.stage) - stageRank(b.stage))
|
: {}
|
||||||
const total = rows.reduce((sum, r) => sum + (r.count || 0), 0)
|
const rows = BOARD_ACTIVE
|
||||||
|
.map((stage) => ({ stage, count: folded[stage] || 0 }))
|
||||||
|
.filter((r) => r.count > 0)
|
||||||
|
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||||
const pal = Charts.PALETTE
|
const pal = Charts.PALETTE
|
||||||
return rows.map((r, i) => ({
|
return rows.map((r, i) => ({
|
||||||
stage: r.stage,
|
...r,
|
||||||
count: r.count,
|
pct: total ? Math.round((r.count / total) * 100) : 0,
|
||||||
pct: total ? Math.round(((r.count || 0) / total) * 100) : 0,
|
|
||||||
color: pal[i % pal.length],
|
color: pal[i % pal.length],
|
||||||
}))
|
}))
|
||||||
}, [funnelQuery.data])
|
}, [funnelQuery.data])
|
||||||
|
|
@ -300,8 +302,8 @@ export default function Dashboard() {
|
||||||
const pipelineDoughnut = useMemo(() => ({
|
const pipelineDoughnut = useMemo(() => ({
|
||||||
labels: pipeRows.map((p) => p.stage),
|
labels: pipeRows.map((p) => p.stage),
|
||||||
data: pipeRows.map((p) => p.count),
|
data: pipeRows.map((p) => p.count),
|
||||||
colors: Charts.PALETTE,
|
colors: pipeRows.map((p) => p.color),
|
||||||
centerValue: pipeRows.reduce((sum, s) => sum + (s.count || 0), 0),
|
centerValue: pipeRows.reduce((sum, s) => sum + s.count, 0),
|
||||||
centerLabel: 'In pipeline',
|
centerLabel: 'In pipeline',
|
||||||
}), [pipeRows])
|
}), [pipeRows])
|
||||||
|
|
||||||
|
|
@ -453,16 +455,16 @@ export default function Dashboard() {
|
||||||
<div>
|
<div>
|
||||||
<h3>Candidate Pipeline</h3>
|
<h3>Candidate Pipeline</h3>
|
||||||
<span className="ch-sub">
|
<span className="ch-sub">
|
||||||
{funnelQuery.isPending ? 'Loading…' : 'Active by stage, rejections excluded'}
|
{funnelQuery.isPending ? 'Loading…' : 'Active by stage'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{funnelQuery.isError ? (
|
{funnelQuery.isError ? (
|
||||||
<EmptyState icon="alert" title="Couldn’t load pipeline">
|
<EmptyState icon="alert" title="Couldn’t load pipeline">
|
||||||
{widgetError(funnelQuery.error, 'analytics.view', 'The server did not return the funnel.')}
|
{widgetError(funnelQuery.error, 'pipeline.view', 'The server did not return the pipeline.')}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
) : pipeRows.length === 0 && funnelQuery.isSuccess ? (
|
) : funnelQuery.isSuccess && pipeRows.length === 0 ? (
|
||||||
<EmptyState icon="inbox" title="No pipeline data yet">
|
<EmptyState icon="inbox" title="No pipeline data yet">
|
||||||
Stage counts appear once applications are in the system.
|
Stage counts appear once applications are in the system.
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
|
|
@ -478,7 +480,7 @@ export default function Dashboard() {
|
||||||
style={{ width: `${r.pct}%`, background: r.color }}
|
style={{ width: `${r.pct}%`, background: r.color }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="pipe-pct">{r.pct}%</span>
|
<span className="pipe-pct" title={`${r.pct}%`}>{r.count}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -46,14 +46,14 @@ const DEFAULT_FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026'
|
||||||
const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet' }
|
const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet' }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side filters for each tab. Email Processed / Rejected use
|
* Server-side filters for each tab. Email Processed / Rejected follow
|
||||||
* Candidate_application_Status (PROCESS / REJECTED). Sheet Forms use
|
* processing_state (the same writes as Import / Shortlist / Reject). Sheet
|
||||||
* form_data.processing_state (same vocabulary as inbox Import/Reject).
|
* Forms use form_data.processing_state.
|
||||||
*/
|
*/
|
||||||
const TAB_FILTERS = {
|
const TAB_FILTERS = {
|
||||||
Unread: { isread: false },
|
Unread: { isread: false },
|
||||||
Processed: { applicationStatus: 'PROCESS' },
|
Processed: { processingState: 'processed' },
|
||||||
Rejected: { applicationStatus: 'REJECTED' },
|
Rejected: { processingState: 'rejected' },
|
||||||
Duplicates: { isDuplicate: true },
|
Duplicates: { isDuplicate: true },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,9 +224,9 @@ function SourceChip({ item }) {
|
||||||
// The dot carries the partner's brand colour; the label uses theme text —
|
// The dot carries the partner's brand colour; the label uses theme text —
|
||||||
// 11px labels in the partner colour failed AA in both themes.
|
// 11px labels in the partner colour failed AA in both themes.
|
||||||
return (
|
return (
|
||||||
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }}>
|
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }} title={item.source}>
|
||||||
<span className="source-dot" />
|
<span className="source-dot" />
|
||||||
{item.source}
|
<span className="source-chip-label">{item.source}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -904,6 +904,7 @@ export default function Inbox() {
|
||||||
qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
|
qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
|
||||||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1104,17 +1105,7 @@ export default function Inbox() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ii-pos">{i.position}</div>
|
<div className="ii-pos">{i.position}</div>
|
||||||
<div className="ii-meta">
|
<div className="ii-aside">
|
||||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
|
|
||||||
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
|
||||||
<Badge>{i.applicationStatus}</Badge>
|
|
||||||
)}
|
|
||||||
{isForms && i.residingCity && (
|
|
||||||
<span className="cell-sub">{i.residingCity}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
|
||||||
<div className="ii-time">
|
<div className="ii-time">
|
||||||
{outlookListTime(i.received)}
|
{outlookListTime(i.received)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1125,6 +1116,16 @@ export default function Inbox() {
|
||||||
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
|
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ii-meta">
|
||||||
|
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
|
||||||
|
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||||||
|
<Badge>{i.applicationStatus}</Badge>
|
||||||
|
)}
|
||||||
|
{isForms && i.residingCity && (
|
||||||
|
<span className="cell-sub">{i.residingCity}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -1310,6 +1311,7 @@ function FormApplicantDetail({
|
||||||
qc.invalidateQueries({ queryKey: qk.mailbox.formRow(vars.recordId) })
|
qc.invalidateQueries({ queryKey: qk.mailbox.formRow(vars.recordId) })
|
||||||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1648,6 +1650,8 @@ function ApplicationDetail({
|
||||||
onSettled: (_res, _err, vars) => {
|
onSettled: (_res, _err, vars) => {
|
||||||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||||
qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
|
qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import AiFieldAssist from '../ui/AiFieldAssist'
|
||||||
import DataTable from '../ui/DataTable'
|
import DataTable from '../ui/DataTable'
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
|
import { Tabs } from '../ui/Tabs'
|
||||||
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
|
@ -26,7 +27,7 @@ import * as jobsApi from '../api/jobs'
|
||||||
import * as jobPostsApi from '../api/jobPosts'
|
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 { JOB_STATUSES } from '../api/jobs'
|
import * as usersApi from '../api/users'
|
||||||
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).
|
||||||
|
|
@ -76,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('')
|
||||||
|
|
@ -90,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
|
||||||
|
|
@ -183,7 +203,7 @@ export default function Jobs() {
|
||||||
if (type && j.type !== type) return false
|
if (type && j.type !== type) return false
|
||||||
if (q) {
|
if (q) {
|
||||||
const term = q.toLowerCase()
|
const term = q.toLowerCase()
|
||||||
const hay = [j.title, j.department, j.recruiter, j.location]
|
const hay = [j.title, j.department, j.recruiter, j.hiringManager, j.location]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
@ -226,6 +246,8 @@ export default function Jobs() {
|
||||||
{ key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ? <Badge className="b-gray">{platformLabel(j.platform)}</Badge> : '—' },
|
{ key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ? <Badge className="b-gray">{platformLabel(j.platform)}</Badge> : '—' },
|
||||||
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> },
|
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> },
|
||||||
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
|
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
|
||||||
|
{ key: 'hiringManager', label: 'Hiring Manager', sortable: true, render: (j) => j.hiringManager || '—' },
|
||||||
|
{ key: 'recruiter', label: 'Recruiter', sortable: true, render: (j) => j.recruiter || '—' },
|
||||||
{
|
{
|
||||||
key: 'created', label: 'Created', sortable: true,
|
key: 'created', label: 'Created', sortable: true,
|
||||||
sortValue: (j) => (j.created ? j.created.getTime() : 0),
|
sortValue: (j) => (j.created ? j.created.getTime() : 0),
|
||||||
|
|
@ -235,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
|
||||||
|
|
@ -253,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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -310,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>
|
||||||
|
|
@ -323,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)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -339,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)
|
||||||
}}
|
}}
|
||||||
|
|
@ -374,9 +399,120 @@ const SECTION_LABEL = {
|
||||||
|
|
||||||
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/jpg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif'
|
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/jpg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif'
|
||||||
const MAX_IMAGE_MB = 5
|
const MAX_IMAGE_MB = 5
|
||||||
|
const ROLE_LABEL = { primary_recruiter: 'Recruiter', hiring_manager: 'Hiring manager' }
|
||||||
|
|
||||||
|
function fmtWhen(value) {
|
||||||
|
const d = value instanceof Date ? value : new Date(value ?? NaN)
|
||||||
|
if (Number.isNaN(d.getTime())) return null
|
||||||
|
const clock = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||||
|
return `${fmtShort(d)} · ${clock}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searchable picker: type to filter, click a row to store the id.
|
||||||
|
* Not free-text — the value is always an option id (or '' when allowEmpty).
|
||||||
|
*/
|
||||||
|
function SearchSelect({
|
||||||
|
options = [],
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Search…',
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
allowEmpty = false,
|
||||||
|
emptyLabel = 'Unassigned',
|
||||||
|
error = false,
|
||||||
|
}) {
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const root = useRef(null)
|
||||||
|
const selected = options.find((o) => String(o.id) === String(value || ''))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onDoc(e) {
|
||||||
|
if (root.current && !root.current.contains(e.target)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDoc)
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const term = q.trim().toLowerCase()
|
||||||
|
const filtered = options.filter((o) => {
|
||||||
|
if (!term) return true
|
||||||
|
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
|
||||||
|
return hay.includes(term)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
|
||||||
|
<input
|
||||||
|
className={error ? 'err' : ''}
|
||||||
|
value={open ? q : (selected?.name || '')}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
placeholder={loading ? 'Loading…' : placeholder}
|
||||||
|
autoComplete="off"
|
||||||
|
onFocus={() => { setOpen(true); setQ('') }}
|
||||||
|
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
|
||||||
|
/>
|
||||||
|
{open && !disabled && !loading && (
|
||||||
|
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
|
||||||
|
{allowEmpty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dropdown-link"
|
||||||
|
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
|
||||||
|
>
|
||||||
|
{emptyLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((o) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={o.id}
|
||||||
|
className="dropdown-link"
|
||||||
|
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
|
||||||
|
>
|
||||||
|
{o.name}
|
||||||
|
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useManagerDirectory() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.managers.directory(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await usersApi.listManagers()
|
||||||
|
return Array.isArray(res?.data) ? res.data : []
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRecruiterDirectory() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.tasks.assignees(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await tasksApi.listAssignees()
|
||||||
|
return Array.isArray(res?.data) ? res.data : []
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
|
const managersQuery = useManagerDirectory()
|
||||||
|
const recruitersQuery = useRecruiterDirectory()
|
||||||
const form = useFormState({
|
const form = useFormState({
|
||||||
|
hiring_manager_id: '',
|
||||||
|
current_recruiter_id: '',
|
||||||
title: '',
|
title: '',
|
||||||
department: '',
|
department: '',
|
||||||
location: '',
|
location: '',
|
||||||
|
|
@ -432,6 +568,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
const v = form.values
|
const v = form.values
|
||||||
const errors = {}
|
const errors = {}
|
||||||
if (!v.title.trim()) errors.title = 'Job title is required'
|
if (!v.title.trim()) errors.title = 'Job title is required'
|
||||||
|
if (!v.hiring_manager_id) errors.hiring_manager_id = 'Hiring manager is required'
|
||||||
const vacancies = Number(v.vacancies)
|
const vacancies = Number(v.vacancies)
|
||||||
if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1'
|
if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1'
|
||||||
const expMin = v.experience_min === '' ? null : Number(v.experience_min)
|
const expMin = v.experience_min === '' ? null : Number(v.experience_min)
|
||||||
|
|
@ -462,6 +599,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
requirements: splitLines(v.requirements),
|
requirements: splitLines(v.requirements),
|
||||||
optional_skills: splitLines(v.optional_skills),
|
optional_skills: splitLines(v.optional_skills),
|
||||||
description: v.description.trim() || null,
|
description: v.description.trim() || null,
|
||||||
|
hiring_manager_id: v.hiring_manager_id,
|
||||||
|
current_recruiter_id: v.current_recruiter_id || undefined,
|
||||||
}, imageFile)
|
}, imageFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -522,6 +661,39 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
<FieldError>{form.errors.title}</FieldError>
|
<FieldError>{form.errors.title}</FieldError>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-field">
|
||||||
|
<label>Hiring manager <span className="req">*</span></label>
|
||||||
|
<SearchSelect
|
||||||
|
options={managersQuery.data ?? []}
|
||||||
|
value={form.values.hiring_manager_id}
|
||||||
|
onChange={(id) => form.setField('hiring_manager_id', id)}
|
||||||
|
placeholder="Search hiring managers…"
|
||||||
|
disabled={busy}
|
||||||
|
loading={managersQuery.isPending}
|
||||||
|
error={Boolean(form.errors.hiring_manager_id)}
|
||||||
|
/>
|
||||||
|
<FieldError>{form.errors.hiring_manager_id}</FieldError>
|
||||||
|
{managersQuery.isError && (
|
||||||
|
<p className="text-muted text-sm">Could not load hiring managers.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label>Recruiter</label>
|
||||||
|
<SearchSelect
|
||||||
|
options={recruitersQuery.data ?? []}
|
||||||
|
value={form.values.current_recruiter_id}
|
||||||
|
onChange={(id) => form.setField('current_recruiter_id', id)}
|
||||||
|
placeholder="Search recruiters…"
|
||||||
|
disabled={busy}
|
||||||
|
loading={recruitersQuery.isPending}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel="Unassigned"
|
||||||
|
/>
|
||||||
|
{recruitersQuery.isError && (
|
||||||
|
<p className="text-muted text-sm">Recruiter list needs tasks.view — you can assign later.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div className="field-label-row">
|
<div className="field-label-row">
|
||||||
<label>Department</label>
|
<label>Department</label>
|
||||||
|
|
@ -671,6 +843,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
|
const managersQuery = useManagerDirectory()
|
||||||
|
const recruitersQuery = useRecruiterDirectory()
|
||||||
const form = useFormState({
|
const form = useFormState({
|
||||||
title: j.title || '',
|
title: j.title || '',
|
||||||
department: j.department || '',
|
department: j.department || '',
|
||||||
|
|
@ -680,6 +854,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
experience_min: j.experienceMin != null ? String(j.experienceMin) : '',
|
experience_min: j.experienceMin != null ? String(j.experienceMin) : '',
|
||||||
experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
|
experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
|
||||||
description: j.description || '',
|
description: j.description || '',
|
||||||
|
hiring_manager_id: j.hiringManagerId || '',
|
||||||
|
current_recruiter_id: j.recruiterId || '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const assistContext = () => ({
|
const assistContext = () => ({
|
||||||
|
|
@ -707,10 +883,11 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
function submit() {
|
function submit() {
|
||||||
if (busy) return
|
if (busy) return
|
||||||
const title = form.values.title.trim()
|
const title = form.values.title.trim()
|
||||||
if (!title) {
|
const errors = {}
|
||||||
form.setErrors({ title: 'Job title is required' })
|
if (!title) errors.title = 'Job title is required'
|
||||||
return
|
if (!form.values.hiring_manager_id) errors.hiring_manager_id = 'Hiring manager is required'
|
||||||
}
|
form.setErrors(errors)
|
||||||
|
if (Object.keys(errors).length) return
|
||||||
onSubmit({
|
onSubmit({
|
||||||
title,
|
title,
|
||||||
department: form.values.department.trim() || null,
|
department: form.values.department.trim() || null,
|
||||||
|
|
@ -720,6 +897,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min),
|
experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min),
|
||||||
experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
|
experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
|
||||||
description: form.values.description.trim() || null,
|
description: form.values.description.trim() || null,
|
||||||
|
hiring_manager_id: form.values.hiring_manager_id,
|
||||||
|
current_recruiter_id: form.values.current_recruiter_id || null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -748,6 +927,32 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
<input className={form.errors.title ? 'err' : ''} value={form.values.title} onChange={(e) => form.setField('title', e.target.value)} disabled={busy} />
|
<input className={form.errors.title ? 'err' : ''} value={form.values.title} onChange={(e) => form.setField('title', e.target.value)} disabled={busy} />
|
||||||
<FieldError>{form.errors.title}</FieldError>
|
<FieldError>{form.errors.title}</FieldError>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label>Hiring manager <span className="req">*</span></label>
|
||||||
|
<SearchSelect
|
||||||
|
options={managersQuery.data ?? []}
|
||||||
|
value={form.values.hiring_manager_id}
|
||||||
|
onChange={(id) => form.setField('hiring_manager_id', id)}
|
||||||
|
placeholder="Search hiring managers…"
|
||||||
|
disabled={busy}
|
||||||
|
loading={managersQuery.isPending}
|
||||||
|
error={Boolean(form.errors.hiring_manager_id)}
|
||||||
|
/>
|
||||||
|
<FieldError>{form.errors.hiring_manager_id}</FieldError>
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label>Recruiter</label>
|
||||||
|
<SearchSelect
|
||||||
|
options={recruitersQuery.data ?? []}
|
||||||
|
value={form.values.current_recruiter_id}
|
||||||
|
onChange={(id) => form.setField('current_recruiter_id', id)}
|
||||||
|
placeholder="Search recruiters…"
|
||||||
|
disabled={busy}
|
||||||
|
loading={recruitersQuery.isPending}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel="Unassigned"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div className="field-label-row">
|
<div className="field-label-row">
|
||||||
<label>Department</label>
|
<label>Department</label>
|
||||||
|
|
@ -796,122 +1001,170 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recruiter ownership of one requisition — GET/POST /job/assignments/*.
|
* Hiring-manager + recruiter pointers on one requisition.
|
||||||
*
|
* Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
|
||||||
* Rows are valid-time intervals and the fetch returns only the OPEN one, so
|
|
||||||
* "the assigned recruiter" is simply the first row back. There is no unassign
|
|
||||||
* route: posting a new assignment closes the previous interval, which is why
|
|
||||||
* the control is a picker with a Save rather than an assign/remove pair.
|
|
||||||
*
|
|
||||||
* The picker is /tasks/assignees/fetch because the server rejects any
|
|
||||||
* non-recruiter with a 422, and that endpoint returns exactly the active
|
|
||||||
* recruiter-role users without needing rbac_users.view.
|
|
||||||
*/
|
*/
|
||||||
function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) {
|
function JobOwnership({ job, canEdit }) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [picked, setPicked] = useState('')
|
const managersQuery = useManagerDirectory()
|
||||||
|
const recruitersQuery = useRecruiterDirectory()
|
||||||
|
|
||||||
const assigneesQuery = useQuery({
|
const patch = useMutation({
|
||||||
queryKey: qk.tasks.assignees(),
|
mutationFn: (body) => jobsApi.update(job.id, body),
|
||||||
queryFn: async () => {
|
|
||||||
const res = await tasksApi.listAssignees()
|
|
||||||
return Array.isArray(res?.data) ? res.data : []
|
|
||||||
},
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const namesById = useMemo(() => {
|
|
||||||
const map = new Map()
|
|
||||||
for (const u of assigneesQuery.data ?? []) map.set(String(u.id), u.name)
|
|
||||||
return map
|
|
||||||
}, [assigneesQuery.data])
|
|
||||||
|
|
||||||
const currentQuery = useQuery({
|
|
||||||
queryKey: qk.assignments.job(jobPostId),
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await assignmentsApi.listJob(jobPostId)
|
|
||||||
const rows = Array.isArray(res?.data) ? res.data : []
|
|
||||||
return rows.map((r) => assignmentsApi.toAssignmentView(r, namesById))
|
|
||||||
},
|
|
||||||
enabled: Boolean(jobPostId),
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const current = currentQuery.data?.[0] ?? null
|
|
||||||
|
|
||||||
const assign = useMutation({
|
|
||||||
mutationFn: (userId) => assignmentsApi.assignJob({ jobPostId, userId }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.assignments.job(jobPostId) })
|
qc.invalidateQueries({ queryKey: qk.assignments.job(job.id) })
|
||||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||||
setPicked('')
|
qc.invalidateQueries({ queryKey: qk.managers.all() })
|
||||||
toast('Recruiter assigned', 'success')
|
toast('Assignment updated', 'success')
|
||||||
},
|
},
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the recruiter.'), 'error'),
|
onError: (err) => toast(friendlyAuthError(err, 'Could not update the assignment.'), 'error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/* current.name resolves only once the assignee list has loaded; the
|
|
||||||
requisition's own recruiter_name is the fallback until then. */
|
|
||||||
const currentName = current?.name
|
|
||||||
|| (current ? namesById.get(String(current.userId)) : null)
|
|
||||||
|| fallbackName
|
|
||||||
|| null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
<div className="mb-16">
|
<div className="mb-16">
|
||||||
<div style={SECTION_LABEL}>Recruiter ownership</div>
|
<div style={SECTION_LABEL}>Ownership</div>
|
||||||
{currentQuery.isError ? (
|
<div className="form-grid" style={{ marginBottom: 12 }}>
|
||||||
<p className="text-muted text-sm">
|
<div className="form-field">
|
||||||
{friendlyAuthError(currentQuery.error, 'Assignments did not load.')}
|
<label>Hiring manager</label>
|
||||||
{' '}Needs the <code>jobs.view</code> permission.
|
{canEdit ? (
|
||||||
</p>
|
<SearchSelect
|
||||||
|
options={managersQuery.data ?? []}
|
||||||
|
value={job.hiringManagerId || ''}
|
||||||
|
onChange={(id) => {
|
||||||
|
if (!id || id === String(job.hiringManagerId || '')) return
|
||||||
|
patch.mutate({ hiring_manager_id: id })
|
||||||
|
}}
|
||||||
|
placeholder="Search hiring managers…"
|
||||||
|
disabled={patch.isPending}
|
||||||
|
loading={managersQuery.isPending}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted text-sm" style={{ marginBottom: canEdit ? 10 : 0 }}>
|
<p className="text-muted text-sm">{job.hiringManager || '—'}</p>
|
||||||
{currentQuery.isPending
|
|
||||||
? 'Loading…'
|
|
||||||
: currentName
|
|
||||||
? <>Owned by <b>{currentName}</b>{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''}</>
|
|
||||||
: 'No recruiter assigned yet.'}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canEdit && !currentQuery.isError && (
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<select
|
|
||||||
className="select"
|
|
||||||
value={picked}
|
|
||||||
disabled={assigneesQuery.isPending || assign.isPending}
|
|
||||||
onChange={(e) => setPicked(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{assigneesQuery.isPending ? 'Loading recruiters…' : 'Assign a recruiter…'}
|
|
||||||
</option>
|
|
||||||
{(assigneesQuery.data ?? []).map((u) => (
|
|
||||||
<option key={u.id} value={u.id}>{u.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
disabled={!picked || assign.isPending}
|
|
||||||
onClick={() => assign.mutate(picked)}
|
|
||||||
>
|
|
||||||
{assign.isPending ? 'Assigning…' : 'Assign'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label>Recruiter</label>
|
||||||
|
{canEdit ? (
|
||||||
|
<SearchSelect
|
||||||
|
options={recruitersQuery.data ?? []}
|
||||||
|
value={job.recruiterId || ''}
|
||||||
|
onChange={(id) => {
|
||||||
|
const next = id || null
|
||||||
|
if (String(next || '') === String(job.recruiterId || '')) return
|
||||||
|
patch.mutate({ current_recruiter_id: next })
|
||||||
|
}}
|
||||||
|
placeholder="Search recruiters…"
|
||||||
|
disabled={patch.isPending}
|
||||||
|
loading={recruitersQuery.isPending}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel="Unassigned"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted text-sm">{job.recruiter || 'No recruiter assigned yet.'}</p>
|
||||||
)}
|
)}
|
||||||
{canEdit && assigneesQuery.isError && (
|
</div>
|
||||||
<p className="text-muted text-sm">
|
</div>
|
||||||
The recruiter list needs the <code>tasks.view</code> permission.
|
{canEdit && recruitersQuery.isError && (
|
||||||
</p>
|
<p className="text-muted text-sm">The recruiter list needs the <code>tasks.view</code> permission.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function JobHistory({ historyQuery, statusQuery }) {
|
||||||
|
const assignments = historyQuery.data ?? []
|
||||||
|
const statusRows = statusQuery.data ?? []
|
||||||
|
|
||||||
|
if (historyQuery.isError && statusQuery.isError) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
{friendlyAuthError(historyQuery.error, 'History did not load.')}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if ((historyQuery.isPending && !historyQuery.data) || (statusQuery.isPending && !statusQuery.data)) {
|
||||||
|
return <p className="text-muted text-sm">Loading…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
...assignments.map((row) => ({
|
||||||
|
kind: 'assignment',
|
||||||
|
id: `a-${row.id}`,
|
||||||
|
at: row.validFrom,
|
||||||
|
row,
|
||||||
|
})),
|
||||||
|
...statusRows.map((row) => ({
|
||||||
|
kind: 'status',
|
||||||
|
id: `s-${row.id}`,
|
||||||
|
at: row.created_at ? new Date(row.created_at) : null,
|
||||||
|
row,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0))
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return <p className="text-muted">No history yet.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="list-tight">
|
||||||
|
{events.map((ev) => (
|
||||||
|
ev.kind === 'status'
|
||||||
|
? <StatusHistoryRow key={ev.id} row={ev.row} at={ev.at} />
|
||||||
|
: <AssignmentHistoryRow key={ev.id} row={ev.row} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusHistoryRow({ row, at }) {
|
||||||
|
const fromLabel = row.from_label || row.from_status
|
||||||
|
const toLabel = row.to_label || row.to_status || '—'
|
||||||
|
const actor = row.changed_by_name || (row.changed_by ? 'Unknown' : 'System')
|
||||||
|
const title = fromLabel ? 'Status changed' : `Opened as ${toLabel}`
|
||||||
|
const change = fromLabel ? `${fromLabel} → ${toLabel}` : null
|
||||||
|
return (
|
||||||
|
<div className="list-row">
|
||||||
|
<span className="kpi-icn i-indigo" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||||
|
<Icon name="refresh" />
|
||||||
|
</span>
|
||||||
|
<div className="lr-main">
|
||||||
|
<div className="lr-title">{title}</div>
|
||||||
|
{change && <div className="lr-sub">{change}</div>}
|
||||||
|
<div className="lr-sub">
|
||||||
|
{[actor, at ? fmtWhen(at) : null].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge>{toLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignmentHistoryRow({ row }) {
|
||||||
|
return (
|
||||||
|
<div className="list-row">
|
||||||
|
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||||
|
<Icon name="user" />
|
||||||
|
</span>
|
||||||
|
<div className="lr-main">
|
||||||
|
<div className="lr-title">{row.name || 'Unassigned'}</div>
|
||||||
|
<div className="lr-sub">
|
||||||
|
{[
|
||||||
|
ROLE_LABEL[row.role] || row.role?.replace(/_/g, ' '),
|
||||||
|
row.validFrom ? fmtShort(row.validFrom) : null,
|
||||||
|
row.validTo ? `→ ${fmtShort(row.validTo)}` : 'current',
|
||||||
|
row.assignedByName ? `by ${row.assignedByName}` : null,
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!row.validTo && <Badge className="b-green">Current</Badge>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* Cover image, when the post has one — fetched with the bearer token into an
|
/* Cover image, when the post has one — fetched with the bearer token into an
|
||||||
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
||||||
simply renders nothing. */
|
simply renders nothing. */
|
||||||
|
|
@ -935,7 +1188,30 @@ 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 historyQuery = useQuery({
|
||||||
|
queryKey: qk.assignments.job(j.id),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await assignmentsApi.listJob(j.id, { currentOnly: false })
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return rows.map((r) => assignmentsApi.toAssignmentView(r))
|
||||||
|
},
|
||||||
|
enabled: Boolean(j.id),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: qk.jobs.statusHistory(j.id),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await jobsApi.listStatusHistory(j.id)
|
||||||
|
return Array.isArray(res?.data) ? res.data : []
|
||||||
|
},
|
||||||
|
enabled: Boolean(j.id),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Job Details"
|
title="Job Details"
|
||||||
|
|
@ -975,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>
|
||||||
|
|
@ -983,6 +1259,17 @@ function JobDetail({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
tabs={[
|
||||||
|
{ key: 'details', label: 'Details' },
|
||||||
|
{ key: 'history', label: 'History', count: historyCount || undefined },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tab === 'details' && (
|
||||||
|
<>
|
||||||
<div className="info-grid mb-18">
|
<div className="info-grid mb-18">
|
||||||
<div className="info-item"><div className="il">Department</div><div className="iv">{j.department || '—'}</div></div>
|
<div className="info-item"><div className="il">Department</div><div className="iv">{j.department || '—'}</div></div>
|
||||||
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
|
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
|
||||||
|
|
@ -992,11 +1279,12 @@ function JobDetail({
|
||||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
|
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
|
||||||
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
|
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
|
||||||
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
|
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
|
||||||
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div>
|
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div>
|
||||||
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
|
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecruiterAssignment jobPostId={j.id} fallbackName={j.recruiter} canEdit={canEdit} />
|
<JobOwnership job={j} canEdit={canEdit} />
|
||||||
|
|
||||||
{j.description && (
|
{j.description && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1013,6 +1301,10 @@ function JobDetail({
|
||||||
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} />}
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
|
@ -53,6 +53,16 @@ export default function Managers() {
|
||||||
const managers = managersQuery.data?.rows ?? []
|
const managers = managersQuery.data?.rows ?? []
|
||||||
const total = managersQuery.data?.total ?? 0
|
const total = managersQuery.data?.total ?? 0
|
||||||
const jobs = jobsQuery.data ?? []
|
const jobs = jobsQuery.data ?? []
|
||||||
|
const openByManager = useMemo(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const j of jobs) {
|
||||||
|
if (j.hiringManagerId && j.status === 'Open') {
|
||||||
|
const key = String(j.hiringManagerId)
|
||||||
|
map[key] = (map[key] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [jobs])
|
||||||
const totalReqs = jobs.filter((j) => j.status === 'Open').length
|
const totalReqs = jobs.filter((j) => j.status === 'Open').length
|
||||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const currentPage = Math.min(page, pages)
|
const currentPage = Math.min(page, pages)
|
||||||
|
|
@ -100,7 +110,7 @@ export default function Managers() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
|
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
|
||||||
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
<div className="stat-mini"><span className="stat-mini-val">{openByManager[String(m.id)] ?? m.openReqs ?? 0}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||||
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
|
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider" style={{ margin: '12px 0' }} />
|
<div className="divider" style={{ margin: '12px 0' }} />
|
||||||
|
|
@ -149,6 +159,16 @@ export default function Managers() {
|
||||||
|
|
||||||
function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast }) {
|
function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast }) {
|
||||||
const [messaging, setMessaging] = useState(false)
|
const [messaging, setMessaging] = useState(false)
|
||||||
|
const mineQuery = useQuery({
|
||||||
|
queryKey: qk.jobs.list({ hiringManagerId: m.id }),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await jobsApi.list({ hiringManagerId: m.id, top: 100 })
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return rows.map(jobsApi.toJobView)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const mine = mineQuery.data ?? jobs.filter((j) => String(j.hiringManagerId) === String(m.id))
|
||||||
|
const openMine = mine.filter((j) => j.status === 'Open')
|
||||||
const send = useMutation({
|
const send = useMutation({
|
||||||
mutationFn: (body) => inboxApi.sendEmail({ to: m.email, subject: body.subject, body: body.body, contentType: 'text' }),
|
mutationFn: (body) => inboxApi.sendEmail({ to: m.email, subject: body.subject, body: body.body, contentType: 'text' }),
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not send the message.'), 'error'),
|
onError: (err) => toast(friendlyAuthError(err, 'Could not send the message.'), 'error'),
|
||||||
|
|
@ -234,8 +254,8 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid g-3" style={{ marginBottom: 18 }}>
|
<div className="grid g-3" style={{ marginBottom: 18 }}>
|
||||||
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
<div className="stat-mini"><span className="stat-mini-val">{openMine.length}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||||
<div className="stat-mini"><span className="stat-mini-val">{jobs.length}</span><span className="stat-mini-lbl">Open Jobs</span></div>
|
<div className="stat-mini"><span className="stat-mini-val">{mine.length}</span><span className="stat-mini-lbl">Jobs</span></div>
|
||||||
<div className="stat-mini">
|
<div className="stat-mini">
|
||||||
<span className="stat-mini-val">{m.email ? 'Yes' : '—'}</span>
|
<span className="stat-mini-val">{m.email ? 'Yes' : '—'}</span>
|
||||||
<span className="stat-mini-lbl">Email on file</span>
|
<span className="stat-mini-lbl">Email on file</span>
|
||||||
|
|
@ -259,14 +279,13 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="form-section-title">Open requisitions</h3>
|
<h3 className="form-section-title">Open requisitions</h3>
|
||||||
<p className="text-muted text-sm" style={{ marginBottom: 10 }}>
|
|
||||||
Jobs are not linked to a hiring-manager id yet, so this list is the current open requisitions rather than this manager's own.
|
|
||||||
</p>
|
|
||||||
<div className="list-tight">
|
<div className="list-tight">
|
||||||
{jobs.filter((j) => j.status === 'Open').length === 0 ? (
|
{mineQuery.isPending ? (
|
||||||
<p className="text-muted">No open requisitions</p>
|
<p className="text-muted">Loading requisitions…</p>
|
||||||
|
) : openMine.length === 0 ? (
|
||||||
|
<p className="text-muted">No open requisitions for this manager</p>
|
||||||
) : (
|
) : (
|
||||||
jobs.filter((j) => j.status === 'Open').slice(0, 8).map((j) => (
|
openMine.slice(0, 8).map((j) => (
|
||||||
<div
|
<div
|
||||||
key={j.id}
|
key={j.id}
|
||||||
className="list-row"
|
className="list-row"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Job Matching — assign each inbound application to exactly one job post.
|
Job Matching — assign a job post to CVs stored with "No job"
|
||||||
|
(CV Import → CV bank, apply_via=cv_bank on manual_upload_candidate).
|
||||||
|
|
||||||
Queue layout mirrors Inbox (Tabs over a .split). Page size defaults to 10;
|
Needs assignment: job_post_id is null. Assign writes job_post_id (and a
|
||||||
skip = (page-1)*limit, same as Inbox. The AI's suggested_job_post_ids land
|
candidate user) so Pipeline / Candidates fetch the row like any other
|
||||||
here as a radiogroup; Assign writes assigned_job_post_id. Nothing is
|
manual upload. Assigned tab is the same origin with a job already linked.
|
||||||
marked read — that stays Inbox's job so this page cannot silently move the
|
|
||||||
inbox nav badge.
|
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
|
||||||
import OpenResumeButton from '../ui/OpenResumeButton'
|
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
import { Tabs } from '../ui/Tabs'
|
import { Tabs } from '../ui/Tabs'
|
||||||
|
|
@ -23,34 +21,26 @@ import { useToast } from '../ui/Toast'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
import * as inboxApi from '../api/inbox'
|
import * as candidatesApi from '../api/candidates'
|
||||||
import * as jobPostsApi from '../api/jobPosts'
|
import * as jobPostsApi from '../api/jobPosts'
|
||||||
import * as s3Api from '../api/s3'
|
import * as s3Api from '../api/s3'
|
||||||
import {
|
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
|
||||||
avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta,
|
|
||||||
} from '../data/seed'
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'needs', label: 'Needs assignment' },
|
{ key: 'needs', label: 'Needs assignment' },
|
||||||
{ key: 'assigned', label: 'Assigned' },
|
{ key: 'assigned', label: 'Assigned' },
|
||||||
{ key: 'none', label: 'No suggestions' },
|
|
||||||
{ key: 'all', label: 'All' },
|
{ key: 'all', label: 'All' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TAB_FILTERS = {
|
const TAB_FILTERS = {
|
||||||
needs: { assigned: false },
|
needs: { assigned: false },
|
||||||
assigned: { assigned: true },
|
assigned: { assigned: true },
|
||||||
none: { assigned: false, noSuggestions: true },
|
|
||||||
all: {},
|
all: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same cap as GET /inbox/all-applications `top`. */
|
|
||||||
const PAGE_SIZE_MAX = 500
|
const PAGE_SIZE_MAX = 500
|
||||||
|
|
||||||
const RESUME_STATUS = {
|
const CV_BANK_META = { icon: 'file', color: 'var(--c2)', channel: 'Upload' }
|
||||||
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
|
|
||||||
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDate(value) {
|
function parseDate(value) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
|
|
@ -58,13 +48,39 @@ function parseDate(value) {
|
||||||
return Number.isNaN(d.getTime()) ? null : d
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceFrom(messageTo) {
|
function mapRow(row) {
|
||||||
const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim()
|
const name = row.name || row.email || row.file_name || 'Unknown'
|
||||||
if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null }
|
return {
|
||||||
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
|
id: String(row.id),
|
||||||
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
|
name,
|
||||||
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
|
initials: initialsOf(name),
|
||||||
return { source: raw.split(',')[0].trim(), sourceMeta: null }
|
color: avatarColor(name),
|
||||||
|
email: row.email || '',
|
||||||
|
position: row.file_name || 'CV bank',
|
||||||
|
source: 'CV bank',
|
||||||
|
sourceMeta: CV_BANK_META,
|
||||||
|
received: parseDate(row.created_at),
|
||||||
|
resumeText: row.resume_text || '',
|
||||||
|
filePath: row.file_path || '',
|
||||||
|
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
||||||
|
assignedPost: row.assigned_job_post || null,
|
||||||
|
status: row.status || null,
|
||||||
|
userId: row.user_id || null,
|
||||||
|
linkedinUrl: row.linkedin_url || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchQueue(params) {
|
||||||
|
const res = await candidatesApi.listMatching(params)
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return { rows: rows.map(mapRow), total: res?.total ?? rows.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetail(recordId) {
|
||||||
|
const res = await candidatesApi.getMatching(recordId)
|
||||||
|
const row = res?.data
|
||||||
|
if (!row) return null
|
||||||
|
return mapRow(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourceChip({ item }) {
|
function SourceChip({ item }) {
|
||||||
|
|
@ -76,106 +92,18 @@ function SourceChip({ item }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function htmlToText(value) {
|
|
||||||
const raw = (value || '').trim()
|
|
||||||
if (!raw) return ''
|
|
||||||
if (!/<[a-z!/]/i.test(raw)) return raw
|
|
||||||
const withBreaks = raw
|
|
||||||
.replace(/<br\s*\/?>/gi, '\n')
|
|
||||||
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
|
|
||||||
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
|
|
||||||
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
|
|
||||||
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapApplication(row) {
|
|
||||||
const name = row.name || row.email || 'Unknown'
|
|
||||||
return {
|
|
||||||
id: String(row.id),
|
|
||||||
name,
|
|
||||||
initials: initialsOf(name),
|
|
||||||
color: avatarColor(name),
|
|
||||||
email: row.email || '',
|
|
||||||
position: row.position || '(no subject)',
|
|
||||||
...sourceFrom(row.source),
|
|
||||||
received: parseDate(row.received),
|
|
||||||
unread: Boolean(row.unread),
|
|
||||||
processing: row.processing || 'Unread',
|
|
||||||
resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending',
|
|
||||||
resumeText: row.resume_text || '',
|
|
||||||
filePath: row.file_path || '',
|
|
||||||
suggestedIds: Array.isArray(row.suggested_job_post_ids)
|
|
||||||
? row.suggested_job_post_ids.map(String)
|
|
||||||
: [],
|
|
||||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
|
||||||
matchStatus: row.match_status || null,
|
|
||||||
matchSummary: row.match_summary || '',
|
|
||||||
matchReasoning: row.match_reasoning || '',
|
|
||||||
matchError: row.match_error || '',
|
|
||||||
matchedAt: parseDate(row.matched_at),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchApplications(params) {
|
|
||||||
const res = await inboxApi.listApplications(params)
|
|
||||||
const rows = Array.isArray(res?.data) ? res.data : []
|
|
||||||
return { rows: rows.map(mapApplication), total: res?.total ?? rows.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDetail(recordId) {
|
|
||||||
const res = await inboxApi.getMessage(recordId)
|
|
||||||
const row = res?.data
|
|
||||||
if (!row) return null
|
|
||||||
const name = row.sender_name || row.fromEmail || 'Unknown'
|
|
||||||
return {
|
|
||||||
id: String(row.id),
|
|
||||||
name,
|
|
||||||
initials: initialsOf(name),
|
|
||||||
color: avatarColor(name),
|
|
||||||
email: row.fromEmail || '',
|
|
||||||
position: row.subject || '(no subject)',
|
|
||||||
// Same value as `position`, kept under its own name: the email panel renders
|
|
||||||
// it as a mail header, not as the candidate's role.
|
|
||||||
subject: row.subject || '',
|
|
||||||
...sourceFrom(row.message_to),
|
|
||||||
body: htmlToText(row.body),
|
|
||||||
// Kept raw for the HTML viewer; `body` stays as the plain-text fallback for
|
|
||||||
// mail that never had markup. EmailBody sanitises before rendering.
|
|
||||||
bodyHtml: row.body || '',
|
|
||||||
resumeText: row.resume_text || '',
|
|
||||||
files: Array.isArray(row.files) ? row.files : [],
|
|
||||||
filePath: row.file_path || '',
|
|
||||||
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
|
|
||||||
processing: row.unread ? 'Unread' : 'Read',
|
|
||||||
suggestedIds: (row.suggested_job_post_ids || []).map(String),
|
|
||||||
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
|
|
||||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
|
||||||
assignedPost: row.assigned_job_post || null,
|
|
||||||
matchStatus: row.match_status || null,
|
|
||||||
matchSummary: row.match_summary || '',
|
|
||||||
matchReasoning: row.match_reasoning || '',
|
|
||||||
matchError: row.match_error || '',
|
|
||||||
matchedAt: parseDate(row.matched_at),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AssignmentBadge({ item, titleById }) {
|
function AssignmentBadge({ item, titleById }) {
|
||||||
if (item.matchStatus === 'processing') {
|
|
||||||
return <Badge className="b-gray">Matching…</Badge>
|
|
||||||
}
|
|
||||||
if (item.assignedId) {
|
if (item.assignedId) {
|
||||||
const title = titleById.get(item.assignedId) || 'Assigned'
|
const title = titleById.get(item.assignedId) || item.assignedPost?.title || 'Assigned'
|
||||||
return <Badge className="b-green">{title}</Badge>
|
return <Badge className="b-green">{title}</Badge>
|
||||||
}
|
}
|
||||||
const n = item.suggestedIds.length
|
return <Badge className="b-amber">No job</Badge>
|
||||||
if (n > 0) return <Badge className="b-blue">{n} suggested</Badge>
|
|
||||||
return <Badge className="b-amber">No match</Badge>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Matching() {
|
export default function Matching() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { can } = useAuth()
|
const { can } = useAuth()
|
||||||
const canEdit = can('inbox.edit')
|
const canEdit = can('candidates.edit')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const deepLink = searchParams.get('record')
|
const deepLink = searchParams.get('record')
|
||||||
|
|
@ -188,7 +116,6 @@ export default function Matching() {
|
||||||
const [selection, setSelection] = useState(null)
|
const [selection, setSelection] = useState(null)
|
||||||
const [manualPost, setManualPost] = useState(null)
|
const [manualPost, setManualPost] = useState(null)
|
||||||
const [showPicker, setShowPicker] = useState(false)
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
const [whyOpen, setWhyOpen] = useState(false)
|
|
||||||
|
|
||||||
const tabFilter = TAB_FILTERS[tab] ?? {}
|
const tabFilter = TAB_FILTERS[tab] ?? {}
|
||||||
const listParams = useMemo(() => ({
|
const listParams = useMemo(() => ({
|
||||||
|
|
@ -199,25 +126,21 @@ export default function Matching() {
|
||||||
}), [tabFilter, page, pageSize, q])
|
}), [tabFilter, page, pageSize, q])
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: qk.mailbox.assignments({ ...listParams, tab }),
|
queryKey: qk.candidates.matching({ ...listParams, tab }),
|
||||||
queryFn: () => fetchApplications(listParams),
|
queryFn: () => fetchQueue(listParams),
|
||||||
})
|
})
|
||||||
|
|
||||||
const needsCount = useQuery({
|
const needsCount = useQuery({
|
||||||
queryKey: qk.mailbox.assignments({ assigned: false, count: true }),
|
queryKey: qk.candidates.matching({ assigned: false, count: true }),
|
||||||
queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0,
|
queryFn: async () => (await candidatesApi.listMatching({ assigned: false, top: 1 }))?.total ?? 0,
|
||||||
})
|
})
|
||||||
const assignedCount = useQuery({
|
const assignedCount = useQuery({
|
||||||
queryKey: qk.mailbox.assignments({ assigned: true, count: true }),
|
queryKey: qk.candidates.matching({ assigned: true, count: true }),
|
||||||
queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0,
|
queryFn: async () => (await candidatesApi.listMatching({ assigned: true, top: 1 }))?.total ?? 0,
|
||||||
})
|
})
|
||||||
const allCount = useQuery({
|
const allCount = useQuery({
|
||||||
queryKey: qk.mailbox.assignments({ count: true }),
|
queryKey: qk.candidates.matching({ count: true }),
|
||||||
queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0,
|
queryFn: async () => (await candidatesApi.listMatching({ top: 1 }))?.total ?? 0,
|
||||||
})
|
|
||||||
const noneCountQuery = useQuery({
|
|
||||||
queryKey: qk.mailbox.assignments({ kind: 'none', count: true }),
|
|
||||||
queryFn: async () => (await inboxApi.listApplications({ assigned: false, noSuggestions: true, top: 1 }))?.total ?? 0,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows = listQuery.data?.rows ?? []
|
const rows = listQuery.data?.rows ?? []
|
||||||
|
|
@ -226,13 +149,10 @@ export default function Matching() {
|
||||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const currentPage = Math.min(page, pages)
|
const currentPage = Math.min(page, pages)
|
||||||
|
|
||||||
const noneCount = noneCountQuery.data ?? 0
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (listQuery.isSuccess && page > pages) setPage(pages)
|
if (listQuery.isSuccess && page > pages) setPage(pages)
|
||||||
}, [listQuery.isSuccess, page, pages])
|
}, [listQuery.isSuccess, page, pages])
|
||||||
|
|
||||||
// Preselect deep link once, then clear the query so refresh doesn't re-pin.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deepLink) return undefined
|
if (!deepLink) return undefined
|
||||||
setSelectedId(deepLink)
|
setSelectedId(deepLink)
|
||||||
|
|
@ -241,7 +161,7 @@ export default function Matching() {
|
||||||
}, [deepLink, setSearchParams])
|
}, [deepLink, setSearchParams])
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: qk.mailbox.message(selectedId),
|
queryKey: qk.candidates.matchingDetail(selectedId),
|
||||||
queryFn: () => fetchDetail(selectedId),
|
queryFn: () => fetchDetail(selectedId),
|
||||||
enabled: Boolean(selectedId),
|
enabled: Boolean(selectedId),
|
||||||
})
|
})
|
||||||
|
|
@ -249,12 +169,10 @@ export default function Matching() {
|
||||||
const detail = detailQuery.data
|
const detail = detailQuery.data
|
||||||
const listRow = filtered.find((r) => r.id === selectedId) || rows.find((r) => r.id === selectedId)
|
const listRow = filtered.find((r) => r.id === selectedId) || rows.find((r) => r.id === selectedId)
|
||||||
|
|
||||||
// Hydrate titles for list badges (assigned + suggestions) in one call.
|
|
||||||
const hydrateIds = useMemo(() => {
|
const hydrateIds = useMemo(() => {
|
||||||
const ids = new Set()
|
const ids = new Set()
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.assignedId) ids.add(r.assignedId)
|
if (r.assignedId) ids.add(r.assignedId)
|
||||||
for (const id of r.suggestedIds) ids.add(id)
|
|
||||||
}
|
}
|
||||||
return [...ids]
|
return [...ids]
|
||||||
}, [rows])
|
}, [rows])
|
||||||
|
|
@ -275,24 +193,11 @@ export default function Matching() {
|
||||||
return map
|
return map
|
||||||
}, [titlesQuery.data])
|
}, [titlesQuery.data])
|
||||||
|
|
||||||
// Reset local selection when the selected application changes.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setManualPost(null)
|
setManualPost(null)
|
||||||
setWhyOpen(false)
|
|
||||||
if (detail?.assignedId) setSelection(detail.assignedId)
|
if (detail?.assignedId) setSelection(detail.assignedId)
|
||||||
else if (detail?.suggestedIds?.[0]) setSelection(detail.suggestedIds[0])
|
|
||||||
else setSelection(null)
|
else setSelection(null)
|
||||||
}, [detail?.id, detail?.assignedId, detail?.suggestedIds])
|
}, [detail?.id, detail?.assignedId])
|
||||||
|
|
||||||
const suggestionCards = useMemo(() => {
|
|
||||||
const fromDetail = detail?.suggestedPosts || []
|
|
||||||
const byId = new Map(fromDetail.map((p) => [String(p.id), p]))
|
|
||||||
const ids = detail?.suggestedIds || listRow?.suggestedIds || []
|
|
||||||
return ids.map((id, i) => ({
|
|
||||||
rank: i + 1,
|
|
||||||
post: byId.get(id) || { id, unavailable: true },
|
|
||||||
}))
|
|
||||||
}, [detail, listRow])
|
|
||||||
|
|
||||||
const selectedPost = useMemo(() => {
|
const selectedPost = useMemo(() => {
|
||||||
if (!selection) return null
|
if (!selection) return null
|
||||||
|
|
@ -300,17 +205,11 @@ export default function Matching() {
|
||||||
if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) {
|
if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) {
|
||||||
return detail.assignedPost
|
return detail.assignedPost
|
||||||
}
|
}
|
||||||
const hit = suggestionCards.find((c) => String(c.post.id) === String(selection))
|
return null
|
||||||
return hit?.post || null
|
}, [selection, manualPost, detail])
|
||||||
}, [selection, manualPost, detail, suggestionCards])
|
|
||||||
|
|
||||||
const assignMutation = useMutation({
|
const assignMutation = useMutation({
|
||||||
mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId),
|
mutationFn: ({ recordId, jobPostId }) => candidatesApi.assignMatchingJob(recordId, jobPostId),
|
||||||
onMutate: async ({ recordId, jobPostId }) => {
|
|
||||||
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
|
|
||||||
await qc.cancelQueries({ queryKey: ['mailbox', 'assignments'] })
|
|
||||||
return { recordId, jobPostId }
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast(friendlyAuthError(err, 'Could not assign job post.'), 'error')
|
toast(friendlyAuthError(err, 'Could not assign job post.'), 'error')
|
||||||
},
|
},
|
||||||
|
|
@ -321,12 +220,12 @@ export default function Matching() {
|
||||||
else toast(`${name} unassigned`, 'success')
|
else toast(`${name} unassigned`, 'success')
|
||||||
},
|
},
|
||||||
onSettled: async (_res, _err, vars) => {
|
onSettled: async (_res, _err, vars) => {
|
||||||
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
|
||||||
await qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] })
|
|
||||||
await qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
|
|
||||||
await qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
await qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||||
|
await qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
||||||
|
await qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||||
|
await qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||||||
|
await qc.invalidateQueries({ queryKey: qk.candidates.matchingDetail(vars.recordId) })
|
||||||
|
|
||||||
// Auto-advance only on the Needs assignment tab after a real assign.
|
|
||||||
if (tab === 'needs' && vars.jobPostId) {
|
if (tab === 'needs' && vars.jobPostId) {
|
||||||
const idx = filtered.findIndex((r) => r.id === vars.recordId)
|
const idx = filtered.findIndex((r) => r.id === vars.recordId)
|
||||||
const next = filtered[idx + 1] || filtered[idx - 1] || null
|
const next = filtered[idx + 1] || filtered[idx - 1] || null
|
||||||
|
|
@ -335,17 +234,6 @@ export default function Matching() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const rematchMutation = useMutation({
|
|
||||||
mutationFn: (recordId) => inboxApi.rematch(recordId),
|
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'),
|
|
||||||
onSuccess: () => toast('Match re-queued', 'success'),
|
|
||||||
onSettled: (_r, _e, recordId) => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) })
|
|
||||||
qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keyboard: j/k move queue, 1–5 pick suggestion, Enter assigns, Esc clears.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
const tag = e.target?.tagName
|
const tag = e.target?.tagName
|
||||||
|
|
@ -360,9 +248,6 @@ export default function Matching() {
|
||||||
const idx = filtered.findIndex((r) => r.id === selectedId)
|
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||||
const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))]
|
const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))]
|
||||||
if (next) setSelectedId(next.id)
|
if (next) setSelectedId(next.id)
|
||||||
} else if (e.key >= '1' && e.key <= '5') {
|
|
||||||
const card = suggestionCards[Number(e.key) - 1]
|
|
||||||
if (card && !card.post.unavailable) setSelection(String(card.post.id))
|
|
||||||
} else if (e.key === 'Enter' && canEdit && selection && selection !== detail?.assignedId) {
|
} else if (e.key === 'Enter' && canEdit && selection && selection !== detail?.assignedId) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
|
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
|
||||||
|
|
@ -373,27 +258,25 @@ export default function Matching() {
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [filtered, selectedId, suggestionCards, canEdit, selection, detail, assignMutation])
|
}, [filtered, selectedId, canEdit, selection, detail, assignMutation])
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
needs: needsCount.data ?? 0,
|
needs: needsCount.data ?? 0,
|
||||||
assigned: assignedCount.data ?? 0,
|
assigned: assignedCount.data ?? 0,
|
||||||
none: noneCount,
|
|
||||||
all: allCount.data ?? 0,
|
all: allCount.data ?? 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const resumeText = detail?.resumeText || listRow?.resumeText || ''
|
|
||||||
const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow)
|
const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow)
|
||||||
const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus)
|
const resumeText = detail?.resumeText || listRow?.resumeText || ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Job Matching" sub="Route applications to the right open role" />
|
<PageHeader title="Job Matching" sub="Assign a job to CVs stored with no job" />
|
||||||
|
|
||||||
{!canEdit && (
|
{!canEdit && (
|
||||||
<div className="alert alert-danger mb-12">
|
<div className="alert alert-danger mb-12">
|
||||||
Your account does not hold <code>inbox.edit</code>, which the server requires to
|
Your account does not hold <code>candidates.edit</code>, which the server requires to
|
||||||
assign, unassign, or retry a match. Controls below stay disabled.
|
assign or unassign a role. Controls below stay disabled.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -425,7 +308,7 @@ export default function Matching() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{listQuery.isPending && (
|
{listQuery.isPending && (
|
||||||
<EmptyState icon="target" title="Loading…">Fetching applications.</EmptyState>
|
<EmptyState icon="target" title="Loading…">Fetching CVs from the bank.</EmptyState>
|
||||||
)}
|
)}
|
||||||
{listQuery.isError && (
|
{listQuery.isError && (
|
||||||
<EmptyState icon="alert" title="Couldn’t load queue">
|
<EmptyState icon="alert" title="Couldn’t load queue">
|
||||||
|
|
@ -435,14 +318,14 @@ export default function Matching() {
|
||||||
{listQuery.isSuccess && filtered.length === 0 && (
|
{listQuery.isSuccess && filtered.length === 0 && (
|
||||||
<EmptyState icon="check-circle" title="Queue clear">
|
<EmptyState icon="check-circle" title="Queue clear">
|
||||||
{tab === 'needs'
|
{tab === 'needs'
|
||||||
? 'Every application in this view has a role.'
|
? 'Every CV from the No job tab has a role, or the bank is empty.'
|
||||||
: 'Nothing matches this filter.'}
|
: 'Nothing matches this filter.'}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
)}
|
)}
|
||||||
{filtered.map((i) => (
|
{filtered.map((i) => (
|
||||||
<div
|
<div
|
||||||
key={i.id}
|
key={i.id}
|
||||||
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
|
className={`inbox-item${selectedId === i.id ? ' active' : ''}`}
|
||||||
onClick={() => setSelectedId(i.id)}
|
onClick={() => setSelectedId(i.id)}
|
||||||
>
|
>
|
||||||
<Avatar name={i.name} initials={i.initials} color={i.color} />
|
<Avatar name={i.name} initials={i.initials} color={i.color} />
|
||||||
|
|
@ -480,13 +363,13 @@ export default function Matching() {
|
||||||
<div className="split-detail">
|
<div className="split-detail">
|
||||||
{!selectedId ? (
|
{!selectedId ? (
|
||||||
<div style={{ padding: '100px 20px' }}>
|
<div style={{ padding: '100px 20px' }}>
|
||||||
<EmptyState icon="target" title="Select an application">
|
<EmptyState icon="target" title="Select a CV">
|
||||||
Choose an item from the list to review suggestions and assign a role.
|
Choose an item from the list to assign a job post.
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
</div>
|
</div>
|
||||||
) : detailQuery.isError ? (
|
) : detailQuery.isError ? (
|
||||||
<div style={{ padding: '100px 20px' }}>
|
<div style={{ padding: '100px 20px' }}>
|
||||||
<EmptyState icon="alert" title="Couldn’t load this application">
|
<EmptyState icon="alert" title="Couldn’t load this CV">
|
||||||
{friendlyAuthError(detailQuery.error, 'Request failed')}
|
{friendlyAuthError(detailQuery.error, 'Request failed')}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -497,15 +380,9 @@ export default function Matching() {
|
||||||
loading={detailQuery.isPending}
|
loading={detailQuery.isPending}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
setSelection={setSelection}
|
|
||||||
manualPost={manualPost}
|
|
||||||
suggestionCards={suggestionCards}
|
|
||||||
selectedPost={selectedPost}
|
selectedPost={selectedPost}
|
||||||
resumeText={resumeText}
|
resumeText={resumeText}
|
||||||
resumeKey={resumeKey}
|
resumeKey={resumeKey}
|
||||||
whyOpen={whyOpen}
|
|
||||||
setWhyOpen={setWhyOpen}
|
|
||||||
matchFailed={matchFailed}
|
|
||||||
onPickManual={() => setShowPicker(true)}
|
onPickManual={() => setShowPicker(true)}
|
||||||
onSkip={() => {
|
onSkip={() => {
|
||||||
const idx = filtered.findIndex((r) => r.id === selectedId)
|
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||||
|
|
@ -521,9 +398,7 @@ export default function Matching() {
|
||||||
assignMutation.mutate({ recordId: selectedId, jobPostId: null })
|
assignMutation.mutate({ recordId: selectedId, jobPostId: null })
|
||||||
}}
|
}}
|
||||||
onChange={() => setShowPicker(true)}
|
onChange={() => setShowPicker(true)}
|
||||||
onRematch={() => rematchMutation.mutate(selectedId)}
|
|
||||||
assigning={assignMutation.isPending}
|
assigning={assignMutation.isPending}
|
||||||
rematching={rematchMutation.isPending}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -549,23 +424,15 @@ function MatchingWorkspace({
|
||||||
loading,
|
loading,
|
||||||
canEdit,
|
canEdit,
|
||||||
selection,
|
selection,
|
||||||
setSelection,
|
|
||||||
manualPost,
|
|
||||||
suggestionCards,
|
|
||||||
selectedPost,
|
selectedPost,
|
||||||
resumeText,
|
resumeText,
|
||||||
resumeKey,
|
resumeKey,
|
||||||
whyOpen,
|
|
||||||
setWhyOpen,
|
|
||||||
matchFailed,
|
|
||||||
onPickManual,
|
onPickManual,
|
||||||
onSkip,
|
onSkip,
|
||||||
onAssign,
|
onAssign,
|
||||||
onUnassign,
|
onUnassign,
|
||||||
onChange,
|
onChange,
|
||||||
onRematch,
|
|
||||||
assigning,
|
assigning,
|
||||||
rematching,
|
|
||||||
}) {
|
}) {
|
||||||
const i = {
|
const i = {
|
||||||
name: detail?.name || listRow?.name || '…',
|
name: detail?.name || listRow?.name || '…',
|
||||||
|
|
@ -574,8 +441,9 @@ function MatchingWorkspace({
|
||||||
position: detail?.position || listRow?.position,
|
position: detail?.position || listRow?.position,
|
||||||
source: detail?.source || listRow?.source,
|
source: detail?.source || listRow?.source,
|
||||||
sourceMeta: detail?.sourceMeta || listRow?.sourceMeta,
|
sourceMeta: detail?.sourceMeta || listRow?.sourceMeta,
|
||||||
processing: detail?.processing || listRow?.processing,
|
email: detail?.email || listRow?.email || '',
|
||||||
resumeStatus: detail?.resumeStatus || listRow?.resumeStatus,
|
received: detail?.received || listRow?.received,
|
||||||
|
linkedinUrl: detail?.linkedinUrl || listRow?.linkedinUrl || null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const assigned = detail?.assignedPost
|
const assigned = detail?.assignedPost
|
||||||
|
|
@ -588,20 +456,28 @@ function MatchingWorkspace({
|
||||||
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
|
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
|
||||||
<div className="ph-role">{i.position}</div>
|
<div className="ph-role">{i.email || i.position}</div>
|
||||||
<div className="ph-tags" style={{ marginTop: 8 }}>
|
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||||||
<SourceChip item={i} />{' '}
|
<SourceChip item={i} />{' '}
|
||||||
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
{i.received && <span className="cell-sub">Added {fmtDate(i.received)}</span>}
|
||||||
{i.resumeStatus}
|
|
||||||
</Badge>
|
|
||||||
{loading && <span className="cell-sub">Loading details…</span>}
|
{loading && <span className="cell-sub">Loading details…</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{s3Api.canOpen(resumeKey) && (
|
{(s3Api.canOpen(resumeKey) || i.linkedinUrl) && (
|
||||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||||||
<OpenResumeButton filePath={resumeKey} />
|
{s3Api.canOpen(resumeKey) && <OpenResumeButton filePath={resumeKey} />}
|
||||||
|
{i.linkedinUrl && (
|
||||||
|
<a
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
href={i.linkedinUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Icon name="linkedin" /> LinkedIn
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -626,10 +502,10 @@ function MatchingWorkspace({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<button className="btn btn-secondary btn-sm" disabled={!canEdit} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onChange}>
|
<button className="btn btn-secondary btn-sm" disabled={!canEdit} title={!canEdit ? 'Requires candidates.edit' : undefined} onClick={onChange}>
|
||||||
Change
|
Change
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-ghost btn-sm" disabled={!canEdit || assigning} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onUnassign}>
|
<button className="btn btn-ghost btn-sm" disabled={!canEdit || assigning} title={!canEdit ? 'Requires candidates.edit' : undefined} onClick={onUnassign}>
|
||||||
Unassign
|
Unassign
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -646,86 +522,29 @@ function MatchingWorkspace({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
||||||
{matchFailed ? (
|
|
||||||
<div className="alert alert-danger mb-16">
|
|
||||||
<div className="mb-8">{detail?.matchError || 'Matching failed for this application.'}</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
disabled={!canEdit || rematching}
|
|
||||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
|
||||||
onClick={onRematch}
|
|
||||||
>
|
|
||||||
<Icon name="sparkles" /> Retry match
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mb-16">
|
|
||||||
<div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div>
|
|
||||||
<p style={{ marginBottom: 4 }}>
|
|
||||||
{detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'}
|
|
||||||
</p>
|
|
||||||
{detail?.matchedAt && (
|
|
||||||
<div className="cell-sub">Matched {fmtDate(detail.matchedAt)}</div>
|
|
||||||
)}
|
|
||||||
{(detail?.matchReasoning || listRow?.matchReasoning) && (
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
style={{ marginTop: 8, paddingLeft: 0 }}
|
|
||||||
onClick={() => setWhyOpen((v) => !v)}
|
|
||||||
>
|
|
||||||
{whyOpen ? '▾' : '▸'} Why these roles?
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{whyOpen && (
|
|
||||||
<p className="text-muted text-sm mt-8">
|
|
||||||
{detail?.matchReasoning || listRow?.matchReasoning}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Email first: it is the application itself, and the resume is its
|
|
||||||
attachment. Reading order follows that. */}
|
|
||||||
{(detail?.subject || detail?.body) && (
|
|
||||||
<div className="mb-16">
|
|
||||||
<div className="fw-600" style={{ marginBottom: 6 }}>Email</div>
|
|
||||||
<div className="email-head">Subject: {detail.subject || '(no subject)'}</div>
|
|
||||||
{looksLikeHtml(detail.bodyHtml) ? (
|
|
||||||
<EmailBody html={detail.bodyHtml} />
|
|
||||||
) : (
|
|
||||||
<pre className="resume-thumb is-full email-plain">
|
|
||||||
{detail.body || 'No email body.'}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="fw-600" style={{ marginBottom: 6 }}>CV</div>
|
<div className="fw-600" style={{ marginBottom: 6 }}>CV</div>
|
||||||
{s3Api.canOpen(resumeKey) ? (
|
{s3Api.canOpen(resumeKey) ? (
|
||||||
<OpenResumeButton filePath={resumeKey} />
|
<OpenResumeButton filePath={resumeKey} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted text-sm">No CV file stored in S3 for this application.</p>
|
<p className="text-muted text-sm">No CV file stored in S3 for this record.</p>
|
||||||
)}
|
)}
|
||||||
|
{resumeText ? (
|
||||||
|
<pre className="resume-thumb is-full" style={{ marginTop: 12, maxHeight: 280, overflow: 'auto' }}>
|
||||||
|
{resumeText}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
<div role="radiogroup" aria-label="Job post" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||||||
<div className="fw-600 mb-8">Suggested roles</div>
|
<div className="fw-600 mb-8">Job post</div>
|
||||||
{suggestionCards.length === 0 && !manualPost ? (
|
{!selectedPost ? (
|
||||||
<EmptyState icon="alert" title="No suggested roles">
|
<EmptyState icon="briefcase" title="No job selected">
|
||||||
<p>No job post was suggested. Choose a role manually.</p>
|
<p>Pick a role for this CV. After assign it is a normal candidate on that job.</p>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
disabled={!canEdit || rematching}
|
|
||||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
|
||||||
onClick={onRematch}
|
|
||||||
>
|
|
||||||
Retry match
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
title={!canEdit ? 'Requires candidates.edit' : undefined}
|
||||||
onClick={onPickManual}
|
onClick={onPickManual}
|
||||||
>
|
>
|
||||||
Choose a role
|
Choose a role
|
||||||
|
|
@ -733,24 +552,12 @@ function MatchingWorkspace({
|
||||||
</div>
|
</div>
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
suggestionCards.map(({ rank, post }) => (
|
|
||||||
<JobCard
|
<JobCard
|
||||||
key={post.id}
|
post={selectedPost}
|
||||||
post={post}
|
|
||||||
rank={rank}
|
|
||||||
selected={String(selection) === String(post.id)}
|
|
||||||
onSelect={(id) => setSelection(id)}
|
|
||||||
resumeText={resumeText}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{manualPost && (
|
|
||||||
<JobCard
|
|
||||||
post={manualPost}
|
|
||||||
rank={0}
|
rank={0}
|
||||||
manual
|
manual
|
||||||
selected={String(selection) === String(manualPost.id)}
|
selected={String(selection) === String(selectedPost.id)}
|
||||||
onSelect={(id) => setSelection(id)}
|
onSelect={() => {}}
|
||||||
resumeText={resumeText}
|
resumeText={resumeText}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -758,10 +565,10 @@ function MatchingWorkspace({
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ width: '100%', marginTop: 8 }}
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
title={!canEdit ? 'Requires candidates.edit' : undefined}
|
||||||
onClick={onPickManual}
|
onClick={onPickManual}
|
||||||
>
|
>
|
||||||
Choose a different role…
|
{selectedPost ? 'Choose a different role…' : 'Choose a role…'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -780,11 +587,10 @@ function MatchingWorkspace({
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={!canAssign}
|
disabled={!canAssign}
|
||||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
title={!canEdit ? 'Requires candidates.edit' : undefined}
|
||||||
onClick={onAssign}
|
onClick={onAssign}
|
||||||
>
|
>
|
||||||
{selectedPost?.title
|
Assign
|
||||||
? 'Assign' :'Assign'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ export const KANBAN_STAGES = [
|
||||||
{ name: 'Assessment', color: 'var(--stage-3)' },
|
{ name: 'Assessment', color: 'var(--stage-3)' },
|
||||||
{ name: 'Interview', color: 'var(--stage-4)' },
|
{ name: 'Interview', color: 'var(--stage-4)' },
|
||||||
{ name: 'Offer', color: 'var(--stage-5)' },
|
{ name: 'Offer', color: 'var(--stage-5)' },
|
||||||
|
{ name: 'Approved', color: 'var(--stage-8)' },
|
||||||
{ name: 'Hired', color: 'var(--stage-6)' },
|
{ name: 'Hired', color: 'var(--stage-6)' },
|
||||||
|
{ name: 'On Hold', color: 'var(--stage-9)' },
|
||||||
{ name: 'Rejected', color: 'var(--stage-7)' },
|
{ name: 'Rejected', color: 'var(--stage-7)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -186,6 +188,9 @@ export default function Pipeline() {
|
||||||
// Stage lives on the inbox row every candidate screen reads, so their
|
// Stage lives on the inbox row every candidate screen reads, so their
|
||||||
// caches are stale too the moment this lands.
|
// caches are stale too the moment this lands.
|
||||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||||
|
// Dashboard / Analytics funnel is a separate cache; without this a drag
|
||||||
|
// to Interview leaves the doughnut on the previous snapshot for up to 60s.
|
||||||
|
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,20 +19,36 @@
|
||||||
/interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not
|
/interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not
|
||||||
per recruiter — the interviews table has no recruiter column — and the card
|
per recruiter — the interviews table has no recruiter column — and the card
|
||||||
says so rather than implying the selected person owns all of it.
|
says so rather than implying the selected person owns all of it.
|
||||||
|
|
||||||
|
Tasks belong here too: GET /tasks/fetch?assignee_id= the selected recruiter
|
||||||
|
is the worklist the prototype filed under Recruiter Hub. Completing a row
|
||||||
|
writes the same /tasks/update the Tasks screen uses, so the two stay in
|
||||||
|
sync. Hidden without tasks.view; the rest of the hub still loads.
|
||||||
|
|
||||||
|
Interviews Today / upcoming / the heatmap join interviews → job_posts via
|
||||||
|
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id) and
|
||||||
|
filter on current_recruiter_id. The leaderboard ranks by completed
|
||||||
|
requisitions (requisition_status=completed), not inbox hires.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import Chart from '../ui/Chart'
|
import Chart from '../ui/Chart'
|
||||||
import Charts from '../lib/charts'
|
import Charts from '../lib/charts'
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
import { Avatar, EmptyState, Icon, KpiCard } from '../ui/primitives'
|
import { Avatar, Badge, EmptyState, Icon, KpiCard, PRIORITY_CLASS, ProgressBar } from '../ui/primitives'
|
||||||
|
import { useToast } from '../ui/Toast'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
import * as analyticsApi from '../api/analytics'
|
import * as analyticsApi from '../api/analytics'
|
||||||
import * as interviewsApi from '../api/interviews'
|
import * as interviewsApi from '../api/interviews'
|
||||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
import * as tasksApi from '../api/tasks'
|
||||||
|
import { avatarColor, fmtShort, initials as initialsOf } from '../data/seed'
|
||||||
|
|
||||||
|
const TASK_PREVIEW = 8
|
||||||
|
|
||||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
const WEEKS = 5
|
const WEEKS = 5
|
||||||
|
|
@ -50,7 +66,12 @@ function weekdayIndex(date) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecruiterHub() {
|
export default function RecruiterHub() {
|
||||||
|
const { can } = useAuth()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const qc = useQueryClient()
|
||||||
const [recruiterId, setRecruiterId] = useState('')
|
const [recruiterId, setRecruiterId] = useState('')
|
||||||
|
const canViewTasks = can('tasks.view')
|
||||||
|
const canEditTasks = can('tasks.edit')
|
||||||
|
|
||||||
const boardQuery = useQuery({
|
const boardQuery = useQuery({
|
||||||
queryKey: qk.analytics.recruiters({ top: 50, scope: 'hub' }),
|
queryKey: qk.analytics.recruiters({ top: 50, scope: 'hub' }),
|
||||||
|
|
@ -86,6 +107,37 @@ export default function RecruiterHub() {
|
||||||
enabled: Boolean(activeId),
|
enabled: Boolean(activeId),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const tasksKey = qk.tasks.list({ assigneeId: activeId, scope: 'hub' })
|
||||||
|
const tasksQuery = useQuery({
|
||||||
|
queryKey: tasksKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await tasksApi.list({ assigneeId: activeId })
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return rows.map(tasksApi.toTaskView)
|
||||||
|
},
|
||||||
|
enabled: Boolean(activeId) && canViewTasks,
|
||||||
|
})
|
||||||
|
|
||||||
|
const flip = useMutation({
|
||||||
|
mutationFn: ({ id, done }) => tasksApi.update(id, { status: done ? 'done' : 'open' }),
|
||||||
|
onMutate: async ({ id, done }) => {
|
||||||
|
await qc.cancelQueries({ queryKey: qk.tasks.all() })
|
||||||
|
const previous = qc.getQueryData(tasksKey)
|
||||||
|
qc.setQueryData(tasksKey, (old = []) =>
|
||||||
|
old.map((t) => (t.id === id ? { ...t, done } : t)),
|
||||||
|
)
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (err, _vars, ctx) => {
|
||||||
|
if (ctx?.previous) qc.setQueryData(tasksKey, ctx.previous)
|
||||||
|
toast(friendlyAuthError(err, 'Could not update the task.'), 'error')
|
||||||
|
},
|
||||||
|
onSuccess: (_res, { done }) => {
|
||||||
|
toast(done ? 'Task completed' : 'Task reopened', done ? 'success' : 'info')
|
||||||
|
},
|
||||||
|
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
|
||||||
|
})
|
||||||
|
|
||||||
/* The heatmap window: the last five whole weeks ending today. Sent as a real
|
/* The heatmap window: the last five whole weeks ending today. Sent as a real
|
||||||
range so the request stays small however long the table gets. */
|
range so the request stays small however long the table gets. */
|
||||||
const heatFrom = useMemo(() => {
|
const heatFrom = useMemo(() => {
|
||||||
|
|
@ -96,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(() => {
|
||||||
|
|
@ -135,7 +189,7 @@ export default function RecruiterHub() {
|
||||||
}, [trendQuery.data])
|
}, [trendQuery.data])
|
||||||
|
|
||||||
const pipelineData = useMemo(() => {
|
const pipelineData = useMemo(() => {
|
||||||
const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED')
|
const rows = analyticsApi.toBoardStageRows(funnelQuery.data ?? [])
|
||||||
return {
|
return {
|
||||||
labels: rows.map((p) => p.stage),
|
labels: rows.map((p) => p.stage),
|
||||||
data: rows.map((p) => p.count),
|
data: rows.map((p) => p.count),
|
||||||
|
|
@ -144,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],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -186,12 +240,27 @@ export default function RecruiterHub() {
|
||||||
const k = kpisQuery.data
|
const k = kpisQuery.data
|
||||||
const name = selected.name || 'Recruiter'
|
const name = selected.name || 'Recruiter'
|
||||||
const loading = kpisQuery.isPending
|
const loading = kpisQuery.isPending
|
||||||
|
const now = new Date()
|
||||||
|
const tasks = tasksQuery.data ?? []
|
||||||
|
const openTasks = tasks.filter((t) => !t.done)
|
||||||
|
const overdueCount = openTasks.filter((t) => t.due && t.due < now).length
|
||||||
|
const doneCount = tasks.filter((t) => t.done).length
|
||||||
|
const taskPct = tasks.length ? Math.round((doneCount / tasks.length) * 100) : 0
|
||||||
|
const tasksLoading = canViewTasks && tasksQuery.isPending
|
||||||
|
|
||||||
|
function toggleTask(task) {
|
||||||
|
if (!canEditTasks) {
|
||||||
|
toast('Requires tasks.edit', 'info')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flip.mutate({ id: task.id, done: !task.done })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Recruiter Hub"
|
title="Recruiter Hub"
|
||||||
sub="Per-recruiter performance, scoped server-side"
|
sub="Per-recruiter hiring progress and assigned tasks"
|
||||||
actions={
|
actions={
|
||||||
<select className="select" value={selected.id} onChange={(e) => setRecruiterId(e.target.value)}>
|
<select className="select" value={selected.id} onChange={(e) => setRecruiterId(e.target.value)}>
|
||||||
{recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
|
{recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
|
||||||
|
|
@ -207,7 +276,15 @@ 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 ? (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
{openTasks.length} open task{openTasks.length === 1 ? '' : 's'}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
|
@ -222,6 +299,14 @@ export default function RecruiterHub() {
|
||||||
</div>
|
</div>
|
||||||
<div style={{ opacity: 0.85, fontSize: 12 }}>Offer acceptance</div>
|
<div style={{ opacity: 0.85, fontSize: 12 }}>Offer acceptance</div>
|
||||||
</div>
|
</div>
|
||||||
|
{canViewTasks && (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 600, letterSpacing: '-0.015em' }}>
|
||||||
|
{tasksLoading ? '—' : (tasks.length ? `${taskPct}%` : '—')}
|
||||||
|
</div>
|
||||||
|
<div style={{ opacity: 0.85, fontSize: 12 }}>Tasks done</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -245,6 +330,26 @@ export default function RecruiterHub() {
|
||||||
<KpiCard label="Offers Accepted" value={loading ? '—' : (k?.offers_accepted ?? 0)} icon="file" tone="i-green" />
|
<KpiCard label="Offers Accepted" value={loading ? '—' : (k?.offers_accepted ?? 0)} icon="file" tone="i-green" />
|
||||||
<KpiCard label="Candidates" value={loading ? '—' : (k?.total_candidates ?? 0)} icon="users" tone="i-indigo" foot="in their pipeline" />
|
<KpiCard label="Candidates" value={loading ? '—' : (k?.total_candidates ?? 0)} icon="users" tone="i-indigo" foot="in their pipeline" />
|
||||||
</div>
|
</div>
|
||||||
|
{canViewTasks && (
|
||||||
|
<div className="grid g-kpi mb-18">
|
||||||
|
<KpiCard label="Open Tasks" value={tasksLoading ? '—' : openTasks.length} icon="check-square" tone="i-indigo" foot="assigned to them" />
|
||||||
|
<KpiCard label="Overdue" value={tasksLoading ? '—' : overdueCount} icon="alert" tone="i-red" foot="past due date" />
|
||||||
|
<KpiCard label="Completed" value={tasksLoading ? '—' : doneCount} icon="check-circle" tone="i-green" foot="of their worklist" />
|
||||||
|
<KpiCard label="Task Progress" value={tasksLoading ? '—' : (tasks.length ? `${taskPct}%` : '—')} icon="target" tone="i-teal" foot={tasks.length ? `${doneCount} of ${tasks.length}` : 'no tasks yet'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewTasks && (
|
||||||
|
<RecruiterTasks
|
||||||
|
name={name}
|
||||||
|
assigneeId={activeId}
|
||||||
|
query={tasksQuery}
|
||||||
|
now={now}
|
||||||
|
canEdit={canEditTasks}
|
||||||
|
toggling={flip.isPending}
|
||||||
|
onToggle={toggleTask}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid g-2-1 mb-18">
|
<div className="grid g-2-1 mb-18">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|
@ -268,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">
|
||||||
|
|
@ -307,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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -318,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) => {
|
||||||
|
|
@ -349,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>
|
||||||
)
|
)
|
||||||
|
|
@ -383,3 +488,95 @@ export default function RecruiterHub() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RecruiterTasks({ name, assigneeId, query, now, canEdit, toggling, onToggle }) {
|
||||||
|
const tasks = query.data ?? []
|
||||||
|
const ranked = [...tasks].sort((a, b) => {
|
||||||
|
const aOver = !a.done && a.due && a.due < now
|
||||||
|
const bOver = !b.done && b.due && b.due < now
|
||||||
|
if (aOver !== bOver) return aOver ? -1 : 1
|
||||||
|
if (a.done !== b.done) return a.done ? 1 : -1
|
||||||
|
const aDue = a.due ? a.due.getTime() : Infinity
|
||||||
|
const bDue = b.due ? b.due.getTime() : Infinity
|
||||||
|
return aDue - bDue
|
||||||
|
})
|
||||||
|
const preview = ranked.slice(0, TASK_PREVIEW)
|
||||||
|
const done = tasks.filter((t) => t.done).length
|
||||||
|
const pctDone = tasks.length ? Math.round((done / tasks.length) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card mb-18">
|
||||||
|
<div className="card-head">
|
||||||
|
<div>
|
||||||
|
<h3>Tasks</h3>
|
||||||
|
<span className="ch-sub">Assigned to {name}</span>
|
||||||
|
</div>
|
||||||
|
<Link className="btn btn-ghost btn-sm" to={assigneeId ? `/tasks?assignee=${encodeURIComponent(assigneeId)}` : '/tasks'}>View all</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="list-tight">
|
||||||
|
{query.isPending ? (
|
||||||
|
<EmptyState icon="clock" title="Loading…">Fetching this recruiter’s tasks.</EmptyState>
|
||||||
|
) : query.isError ? (
|
||||||
|
<EmptyState icon="alert" title="Couldn’t load tasks">
|
||||||
|
{friendlyAuthError(query.error, 'The server did not answer.')}
|
||||||
|
{' '}This list needs the <code>tasks.view</code> permission.
|
||||||
|
</EmptyState>
|
||||||
|
) : preview.length === 0 ? (
|
||||||
|
<EmptyState icon="check-square" title="No tasks assigned">
|
||||||
|
Create a task on the Tasks screen and assign it to {name}.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{preview.map((t) => {
|
||||||
|
const overdue = !t.done && t.due && t.due < now
|
||||||
|
return (
|
||||||
|
<div className="list-row" style={{ alignItems: 'center' }} key={t.id}>
|
||||||
|
<span
|
||||||
|
className={`checkbox ${t.done ? 'on' : ''}`}
|
||||||
|
onClick={() => onToggle(t)}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={t.done}
|
||||||
|
tabIndex={canEdit ? 0 : -1}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggle(t)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="check" />
|
||||||
|
</span>
|
||||||
|
<div className="lr-main">
|
||||||
|
<div
|
||||||
|
className="lr-title"
|
||||||
|
style={t.done ? { textDecoration: 'line-through', color: 'var(--text-3)' } : undefined}
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="lr-sub"
|
||||||
|
style={overdue ? { color: 'var(--danger)', fontWeight: 600 } : undefined}
|
||||||
|
>
|
||||||
|
{t.due ? `${overdue ? 'Overdue · ' : 'Due '}${fmtShort(t.due)}` : 'No due date'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={PRIORITY_CLASS[t.priority]}>{t.priority}</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="task-foot">
|
||||||
|
<ProgressBar pct={pctDone} />
|
||||||
|
<span>
|
||||||
|
{done} of {tasks.length}
|
||||||
|
{ranked.length > TASK_PREVIEW ? ` · showing ${TASK_PREVIEW}` : ''}
|
||||||
|
{toggling ? ' · saving…' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,17 +58,16 @@ const RANGES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
/* Order matters: "reached" is a running sum from the end of this list back to
|
/* Order matters: "reached" is a running sum from the end of this list back to
|
||||||
the start. REJECTED is deliberately absent — see the header note. */
|
the start. REJECTED, CLOSED (shown as Rejected on the board) and ONHOLD are
|
||||||
|
absent — parking and outcomes are not a step in the happy-path suffix sum. */
|
||||||
const FUNNEL_ORDER = [
|
const FUNNEL_ORDER = [
|
||||||
{ key: 'PENDING', label: 'Shortlist' },
|
{ key: 'PENDING', label: 'Shortlist' },
|
||||||
{ key: 'CLOSED', label: 'Shortlist' },
|
|
||||||
{ key: 'SCREENING', label: 'Screened' },
|
{ key: 'SCREENING', label: 'Screened' },
|
||||||
{ key: 'PROCESS', label: 'Screened' },
|
{ key: 'PROCESS', label: 'Screened' },
|
||||||
{ key: 'ONHOLD', label: 'Screened' },
|
|
||||||
{ key: 'ASSESSMENT', label: 'Assessed' },
|
{ key: 'ASSESSMENT', label: 'Assessed' },
|
||||||
{ key: 'INTERVIEW', label: 'Interviewed' },
|
{ key: 'INTERVIEW', label: 'Interviewed' },
|
||||||
{ key: 'OFFER', label: 'Offered' },
|
{ key: 'OFFER', label: 'Offered' },
|
||||||
{ key: 'APPROVED', label: 'Hired' },
|
{ key: 'APPROVED', label: 'Approved' },
|
||||||
{ key: 'HIRED', label: 'Hired' },
|
{ key: 'HIRED', label: 'Hired' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@
|
||||||
The layout, the toolbar, the card and the 8-tab profile modal are the
|
The layout, the toolbar, the card and the 8-tab profile modal are the
|
||||||
originals, unchanged. Only the data source moved.
|
originals, unchanged. Only the data source moved.
|
||||||
|
|
||||||
The endpoint returns name, email, experience, application_status and the
|
The endpoint returns name, email, experience, application_status, suggested
|
||||||
suggested job title. It has no aiScore, skills, currentCompany, source or
|
job titles and the attached job_posts (with department). It has no skills or
|
||||||
department — the agent writes a verdict and prose, not a score, and no
|
currentCompany on list rows — those still come from the seed overlay. Each
|
||||||
résumé-derived skills are persisted. Each record is therefore OVERLAID on a
|
record is therefore OVERLAID on a seed candidate: real values win, seed fills
|
||||||
seed candidate: real values win, seed fills the rest, so the card renders
|
the rest, so the card renders exactly as it always did.
|
||||||
exactly as it always did.
|
|
||||||
|
|
||||||
Clicking a card opens CandidateProfile in place. It used to deep-link into
|
Clicking a card opens CandidateProfile in place. It used to deep-link into
|
||||||
/candidates, which stopped resolving once the ids became real user_ids.
|
/candidates, which stopped resolving once the ids became real user_ids.
|
||||||
|
|
@ -51,17 +50,7 @@ import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||||
/** Backend GET /candidate/fetch caps `limit` at 100. */
|
/** Backend GET /candidate/fetch caps `limit` at 100. */
|
||||||
const PAGE_SIZE_MAX = 100
|
const PAGE_SIZE_MAX = 100
|
||||||
|
|
||||||
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||||
|
|
||||||
/**
|
|
||||||
* Candidate_application_Status (backend/inbox/enums.py) -> the seed stage
|
|
||||||
* vocabulary every screen renders. CLOSED is the column default, i.e. untriaged,
|
|
||||||
* so it reads as Shortlist rather than as an outcome.
|
|
||||||
*/
|
|
||||||
const STAGE_FROM_STATUS = {
|
|
||||||
PENDING: 'Shortlist', CLOSED: 'Shortlist', PROCESS: 'Screening',
|
|
||||||
ONHOLD: 'Screening', APPROVED: 'Hired', REJECTED: 'Rejected',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "6 years" -> 6. The column is free text, so anything unparseable defers to seed. */
|
/** "6 years" -> 6. The column is free text, so anything unparseable defers to seed. */
|
||||||
function years(value) {
|
function years(value) {
|
||||||
|
|
@ -69,6 +58,25 @@ function years(value) {
|
||||||
return Number.isFinite(n) ? n : null
|
return Number.isFinite(n) ? n : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct departments from the candidate's assigned + suggested job posts.
|
||||||
|
* Seed templates also carry a department, but that is a prototype leftover and
|
||||||
|
* must not drive the toolbar filter — it would never match /job/departments/fetch.
|
||||||
|
*/
|
||||||
|
function departmentsOf(row) {
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
const add = (value) => {
|
||||||
|
const d = typeof value === 'string' ? value : ''
|
||||||
|
if (!d || seen.has(d)) return
|
||||||
|
seen.add(d)
|
||||||
|
out.push(d)
|
||||||
|
}
|
||||||
|
add(row.assigned_job_post?.department)
|
||||||
|
for (const jp of row.job_posts || []) add(jp.department)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One API record overlaid on one seed candidate.
|
* One API record overlaid on one seed candidate.
|
||||||
*
|
*
|
||||||
|
|
@ -82,8 +90,9 @@ function merge(row, template) {
|
||||||
const title = (row.job_posts || []).map((j) => j.title).find(Boolean)
|
const title = (row.job_posts || []).map((j) => j.title).find(Boolean)
|
||||||
|| row.job_title
|
|| row.job_title
|
||||||
|| row.current_title
|
|| row.current_title
|
||||||
const stage = STAGE_FROM_STATUS[row.application_status] || template.stage
|
const stage = pipelineApi.STAGE_FROM_STATUS[row.application_status] || template.stage
|
||||||
const experience = years(row.experience)
|
const experience = years(row.experience)
|
||||||
|
const departments = departmentsOf(row)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...template,
|
...template,
|
||||||
|
|
@ -97,6 +106,10 @@ function merge(row, template) {
|
||||||
status: stage,
|
status: stage,
|
||||||
currentTitle: title || template.currentTitle,
|
currentTitle: title || template.currentTitle,
|
||||||
jobTitle: title || template.jobTitle,
|
jobTitle: title || template.jobTitle,
|
||||||
|
// Live job-post departments only. Seed department is left on `department`
|
||||||
|
// for the seed-only profile modal, but the filter reads `departments`.
|
||||||
|
departments,
|
||||||
|
department: departments[0] || template.department,
|
||||||
// Prefer real Form / platform tags from manual_upload; seed only as fallback.
|
// Prefer real Form / platform tags from manual_upload; seed only as fallback.
|
||||||
source: row.source || template.source,
|
source: row.source || template.source,
|
||||||
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
|
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
|
||||||
|
|
@ -185,7 +198,7 @@ export default function TalentPool() {
|
||||||
const list = useMemo(
|
const list = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pool.filter((c) => {
|
pool.filter((c) => {
|
||||||
if (dept && c.department !== dept) return false
|
if (dept && !(c.departments || []).includes(dept)) return false
|
||||||
if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
|
if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
|
|
@ -235,7 +248,7 @@ export default function TalentPool() {
|
||||||
</div>
|
</div>
|
||||||
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
||||||
<option value="">All Departments</option>
|
<option value="">All Departments</option>
|
||||||
{departments.map((d) => <option key={d}>{d}</option>)}
|
{departments.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<PageSizeField
|
<PageSizeField
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
|
|
@ -54,10 +54,12 @@ export default function Tasks() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { can, user } = useAuth()
|
const { can, user } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const canCreate = can('tasks.create') && CREATOR_ROLES.includes(user?.role_name)
|
const canCreate = can('tasks.create') && CREATOR_ROLES.includes(user?.role_name)
|
||||||
const canEdit = can('tasks.edit')
|
const canEdit = can('tasks.edit')
|
||||||
|
const assigneeFilter = searchParams.get('assignee') || ''
|
||||||
|
|
||||||
const tasksQuery = useQuery({ queryKey: qk.tasks.list(), queryFn: fetchTasks })
|
const tasksQuery = useQuery({ queryKey: qk.tasks.list(), queryFn: fetchTasks })
|
||||||
const assigneesQuery = useQuery({ queryKey: qk.tasks.assignees(), queryFn: fetchAssignees })
|
const assigneesQuery = useQuery({ queryKey: qk.tasks.assignees(), queryFn: fetchAssignees })
|
||||||
|
|
@ -73,17 +75,30 @@ export default function Tasks() {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const isOverdue = (t) => !t.done && t.due && t.due < now
|
const isOverdue = (t) => !t.done && t.due && t.due < now
|
||||||
|
|
||||||
const list = useMemo(() => {
|
const scoped = useMemo(() => {
|
||||||
if (filter === 'Open') return tasks.filter((t) => !t.done)
|
if (!assigneeFilter) return tasks
|
||||||
if (filter === 'Completed') return tasks.filter((t) => t.done)
|
return tasks.filter((t) => String(t.assigneeId) === String(assigneeFilter))
|
||||||
if (filter === 'Overdue') return tasks.filter(isOverdue)
|
}, [tasks, assigneeFilter])
|
||||||
if (['High', 'Medium', 'Low'].includes(filter)) return tasks.filter((t) => t.priority === filter)
|
|
||||||
return tasks
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [tasks, filter])
|
|
||||||
|
|
||||||
const openCount = tasks.filter((t) => !t.done).length
|
const assigneeLabel = useMemo(() => {
|
||||||
const overdueCount = tasks.filter(isOverdue).length
|
if (!assigneeFilter) return null
|
||||||
|
const fromTask = scoped.find((t) => t.assignee)?.assignee
|
||||||
|
if (fromTask) return fromTask
|
||||||
|
const fromPicker = (assigneesQuery.data ?? []).find((u) => String(u.id) === String(assigneeFilter))
|
||||||
|
return fromPicker?.name || null
|
||||||
|
}, [assigneeFilter, scoped, assigneesQuery.data])
|
||||||
|
|
||||||
|
const list = useMemo(() => {
|
||||||
|
if (filter === 'Open') return scoped.filter((t) => !t.done)
|
||||||
|
if (filter === 'Completed') return scoped.filter((t) => t.done)
|
||||||
|
if (filter === 'Overdue') return scoped.filter(isOverdue)
|
||||||
|
if (['High', 'Medium', 'Low'].includes(filter)) return scoped.filter((t) => t.priority === filter)
|
||||||
|
return scoped
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [scoped, filter])
|
||||||
|
|
||||||
|
const openCount = scoped.filter((t) => !t.done).length
|
||||||
|
const overdueCount = scoped.filter(isOverdue).length
|
||||||
|
|
||||||
// Optimistic flip with rollback: the checkbox must not lag the click, but a
|
// Optimistic flip with rollback: the checkbox must not lag the click, but a
|
||||||
// 403/422 must snap it back rather than lie.
|
// 403/422 must snap it back rather than lie.
|
||||||
|
|
@ -158,7 +173,11 @@ export default function Tasks() {
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Tasks"
|
title="Tasks"
|
||||||
sub={`${openCount} open · ${overdueCount} overdue`}
|
sub={
|
||||||
|
assigneeFilter
|
||||||
|
? `${openCount} open · ${overdueCount} overdue · ${assigneeLabel || 'this recruiter'}`
|
||||||
|
: `${openCount} open · ${overdueCount} overdue`
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|
@ -181,6 +200,17 @@ export default function Tasks() {
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{assigneeFilter && (
|
||||||
|
<div className="flex items-center flex-wrap" style={{ gap: 8, marginTop: 12 }}>
|
||||||
|
<span className="text-muted" style={{ fontSize: 13 }}>
|
||||||
|
From Recruiter Hub
|
||||||
|
{assigneeLabel ? ` · ${assigneeLabel}` : ''}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/tasks')}>
|
||||||
|
Show all recruiters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="list-tight">
|
<div className="list-tight">
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@
|
||||||
|
|
||||||
--stage-1: #0e7490; --stage-2: #5b60e8; --stage-3: #8a5a00; --stage-4: #004d43;
|
--stage-1: #0e7490; --stage-2: #5b60e8; --stage-3: #8a5a00; --stage-4: #004d43;
|
||||||
--stage-5: #0f9d76; --stage-6: #6f8f14; --stage-7: #b3243a;
|
--stage-5: #0f9d76; --stage-6: #6f8f14; --stage-7: #b3243a;
|
||||||
|
--stage-8: #0b6e4f; --stage-9: #b45309;
|
||||||
|
|
||||||
--ring: 0 0 0 3px rgba(0,77,67,.20);
|
--ring: 0 0 0 3px rgba(0,77,67,.20);
|
||||||
/* select chevron: whole url() is tokenised so the stroke can follow the theme */
|
/* select chevron: whole url() is tokenised so the stroke can follow the theme */
|
||||||
|
|
@ -186,6 +187,7 @@
|
||||||
|
|
||||||
--stage-1: #5fd3e8; --stage-2: #8e92ff; --stage-3: #f5c451; --stage-4: #ceff71;
|
--stage-1: #5fd3e8; --stage-2: #8e92ff; --stage-3: #f5c451; --stage-4: #ceff71;
|
||||||
--stage-5: #25e9a5; --stage-6: #a8e063; --stage-7: #ff7a8a;
|
--stage-5: #25e9a5; --stage-6: #a8e063; --stage-7: #ff7a8a;
|
||||||
|
--stage-8: #5ee0b5; --stage-9: #ffb020;
|
||||||
|
|
||||||
--ring: 0 0 0 3px rgba(206,255,113,.28);
|
--ring: 0 0 0 3px rgba(206,255,113,.28);
|
||||||
--chev-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238fada6' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
--chev-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238fada6' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||||
|
|
@ -993,13 +995,42 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
||||||
/* Inbox sidebar only: fit the list instead of scrolling sideways.
|
/* Inbox sidebar only: fit the list instead of scrolling sideways.
|
||||||
Username (.ii-name) and subject (.ii-pos) are left alone. */
|
Username (.ii-name) and subject (.ii-pos) are left alone. */
|
||||||
.inbox-split { grid-template-columns: minmax(0, 380px) 1fr; }
|
.inbox-split { grid-template-columns: minmax(0, 420px) 1fr; }
|
||||||
.inbox-queue { overflow-x: hidden; min-width: 0; }
|
.inbox-queue { overflow-x: hidden; min-width: 0; }
|
||||||
.inbox-queue .inbox-item { min-width: 0; }
|
.inbox-queue .inbox-item { min-width: 0; }
|
||||||
.inbox-queue .ii-meta { flex-wrap: wrap; min-width: 0; }
|
/* Name + time on row 1, subject on row 2, chips span the full width under
|
||||||
|
the timestamp so a board address stays on one line. */
|
||||||
|
.inbox-queue .ii-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
column-gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.inbox-queue .ii-name { grid-column: 1; grid-row: 1; }
|
||||||
|
.inbox-queue .ii-pos { grid-column: 1; grid-row: 2; }
|
||||||
|
.inbox-queue .ii-aside { grid-column: 2; grid-row: 1 / span 2; text-align: right; }
|
||||||
|
.inbox-queue .ii-meta {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 3;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: visible;
|
||||||
|
row-gap: 6px;
|
||||||
|
}
|
||||||
|
/* One-line To-address pill: grow with the address, never wrap (wrapping
|
||||||
|
stretched the chip taller). Badges sit on the next row. */
|
||||||
.inbox-queue .source-chip {
|
.inbox-queue .source-chip {
|
||||||
min-width: 0; max-width: 100%;
|
flex: 0 0 auto;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.inbox-queue .source-chip-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: normal;
|
||||||
}
|
}
|
||||||
.inbox-queue .toolbar-search { min-width: 0; }
|
.inbox-queue .toolbar-search { min-width: 0; }
|
||||||
.inbox-queue .pagination {
|
.inbox-queue .pagination {
|
||||||
|
|
@ -1050,7 +1081,8 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
11px copy keeps its contrast in both modes. */
|
11px copy keeps its contrast in both modes. */
|
||||||
.source-chip {
|
.source-chip {
|
||||||
display: inline-flex; align-items: center; gap: 5px;
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
font-size: var(--fs-xs); font-weight: 600; padding: 2px 8px; border-radius: 20px;
|
font-size: var(--fs-xs); font-weight: 600; line-height: 1.35;
|
||||||
|
padding: 3px 8px; border-radius: 20px;
|
||||||
--chip: var(--text-3);
|
--chip: var(--text-3);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
background: var(--bg-sunken); /* fallback: color-mix needs Safari 16.2+ / Chrome 111+ */
|
background: var(--bg-sunken); /* fallback: color-mix needs Safari 16.2+ / Chrome 111+ */
|
||||||
|
|
@ -1058,6 +1090,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
}
|
}
|
||||||
.source-chip svg { width: 12px; height: 12px; color: var(--chip); }
|
.source-chip svg { width: 12px; height: 12px; color: var(--chip); }
|
||||||
.source-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--chip); }
|
.source-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--chip); }
|
||||||
|
.source-chip-label { min-width: 0; }
|
||||||
|
|
||||||
.integration-status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 20px; font-size: var(--fs-sm); font-weight: 600; background: var(--success-soft); color: var(--success); }
|
.integration-status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 20px; font-size: var(--fs-sm); font-weight: 600; background: var(--success-soft); color: var(--success); }
|
||||||
.integration-status.pending { background: var(--warning-soft); color: var(--warning); }
|
.integration-status.pending { background: var(--warning-soft); color: var(--warning); }
|
||||||
|
|
|
||||||
|
|
@ -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