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/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/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 {
|
||||
"id": str(user_id) if user_id else None,
|
||||
"name": name,
|
||||
"hires": int(hires or 0),
|
||||
"completed": int(completed or 0),
|
||||
"open_reqs": int(open_reqs or 0),
|
||||
"avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import uuid
|
||||
from datetime import datetime,timedelta,timezone
|
||||
|
||||
from sqlalchemy import and_,func,or_,select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from analytics.serializers import (
|
||||
|
|
@ -12,24 +10,16 @@ from analytics.serializers import (
|
|||
from inbox.enums import Candidate_application_Status
|
||||
from inbox.models import Inbox,Inbox_Messages,SourceChannels
|
||||
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.job_post.enums import RequisitionStatus
|
||||
from job.job_post.models import JobPosts
|
||||
from offer.models import Offers
|
||||
from org_settings.models import OrgSettings
|
||||
from role.models import EnumRoles,Roles
|
||||
from role.models import EnumRoles
|
||||
from users.models import Users
|
||||
|
||||
|
||||
def _as_uuid(value):
|
||||
if value in (None,""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError,ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _month_start(dt: datetime) -> datetime:
|
||||
return datetime(dt.year,dt.month,1,tzinfo=timezone.utc)
|
||||
|
||||
|
|
@ -75,220 +65,29 @@ def _month_key(dt):
|
|||
return datetime(dt.year,dt.month,1,tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _days_expr(end_col,start_col):
|
||||
return func.extract("epoch",end_col-start_col)/86400.0
|
||||
|
||||
|
||||
class Analytics:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def _count_jobs(self,status,from_date=None,to_date=None,department=None,recruiter_id=None,*,closed_in_window=False):
|
||||
statement=select(func.count()).select_from(JobPosts).where(JobPosts.is_deleted==False) # noqa: E712
|
||||
if status:
|
||||
statement=statement.where(JobPosts.requisition_status==status)
|
||||
if department:
|
||||
statement=statement.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement=statement.where(JobPosts.current_recruiter_id==rid)
|
||||
if closed_in_window:
|
||||
if from_date is not None:
|
||||
statement=statement.where(JobPosts.closed_at>=from_date)
|
||||
if to_date is not None:
|
||||
statement=statement.where(JobPosts.closed_at<to_date)
|
||||
result=await self.session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
async def _count_open_snapshot(self,as_of,department=None,recruiter_id=None):
|
||||
"""Jobs that existed and were still open at `as_of` (best-effort)."""
|
||||
statement=select(func.count()).select_from(JobPosts).where(
|
||||
JobPosts.is_deleted==False, # noqa: E712
|
||||
JobPosts.created_at<as_of,
|
||||
or_(JobPosts.closed_at.is_(None),JobPosts.closed_at>=as_of),
|
||||
JobPosts.requisition_status=="open",
|
||||
)
|
||||
if department:
|
||||
statement=statement.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement=statement.where(JobPosts.current_recruiter_id==rid)
|
||||
result=await self.session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
async def _count_candidates(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
statement=(
|
||||
select(func.count())
|
||||
.select_from(Inbox)
|
||||
.join(Users,Inbox.user_id==Users.id)
|
||||
.join(Roles,Users.role_id==Roles.id)
|
||||
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
||||
)
|
||||
if from_date is not None:
|
||||
statement=statement.where(Inbox.created_at>=from_date)
|
||||
if to_date is not None:
|
||||
statement=statement.where(Inbox.created_at<to_date)
|
||||
# Best-effort department/recruiter via linked message → job post
|
||||
if department or recruiter_id:
|
||||
statement=(
|
||||
statement
|
||||
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
||||
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||
)
|
||||
if department:
|
||||
statement=statement.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement=statement.where(
|
||||
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
||||
)
|
||||
result=await self.session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
async def _count_offers(self,statuses,from_date=None,to_date=None,department=None,recruiter_id=None,*,exclude_draft=False):
|
||||
statement=select(func.count()).select_from(Offers)
|
||||
if exclude_draft:
|
||||
statement=statement.where(Offers.status!="draft")
|
||||
elif statuses:
|
||||
statement=statement.where(Offers.status.in_(statuses))
|
||||
stamp=func.coalesce(Offers.sent_at,Offers.responded_at,Offers.created_at)
|
||||
if from_date is not None:
|
||||
statement=statement.where(stamp>=from_date)
|
||||
if to_date is not None:
|
||||
statement=statement.where(stamp<to_date)
|
||||
if department or recruiter_id:
|
||||
statement=statement.outerjoin(JobPosts,Offers.job_post_id==JobPosts.id)
|
||||
if department:
|
||||
statement=statement.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement=statement.where(JobPosts.current_recruiter_id==rid)
|
||||
result=await self.session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
async def _count_hires(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
# Prefer HIRED transitions in window; fall back path uses inbox_messages status.
|
||||
hired=ApplicationStageTransitions
|
||||
statement=select(func.count()).select_from(hired).where(hired.to_stage==Candidate_application_Status.HIRED.value)
|
||||
if from_date is not None:
|
||||
statement=statement.where(hired.valid_from>=from_date)
|
||||
if to_date is not None:
|
||||
statement=statement.where(hired.valid_from<to_date)
|
||||
if department or recruiter_id:
|
||||
statement=(
|
||||
statement
|
||||
.outerjoin(Inbox,hired.inbox_id==Inbox.id)
|
||||
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
||||
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||
)
|
||||
if department:
|
||||
statement=statement.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement=statement.where(
|
||||
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
||||
)
|
||||
result=await self.session.execute(statement)
|
||||
count=int(result.scalar_one() or 0)
|
||||
count=await ApplicationStageTransitions.count_hires(
|
||||
self.session,from_date=from_date,to_date=to_date,
|
||||
department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
if count:
|
||||
return count
|
||||
# Fallback: messages currently HIRED, windowed via inbox.created_at
|
||||
msg=(
|
||||
select(func.count())
|
||||
.select_from(Inbox_Messages)
|
||||
.join(Inbox,Inbox.message_id==Inbox_Messages.id)
|
||||
.where(Inbox_Messages.application_status==Candidate_application_Status.HIRED)
|
||||
return await Inbox_Messages.count_hired(
|
||||
self.session,from_date=from_date,to_date=to_date,
|
||||
department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
if from_date is not None:
|
||||
msg=msg.where(Inbox.created_at>=from_date)
|
||||
if to_date is not None:
|
||||
msg=msg.where(Inbox.created_at<to_date)
|
||||
if department or recruiter_id:
|
||||
msg=msg.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||
if department:
|
||||
msg=msg.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
msg=msg.where(or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid))
|
||||
result=await self.session.execute(msg)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
async def _avg_time_to_hire(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
entry=ApplicationStageTransitions.__table__.alias("entry")
|
||||
hire=ApplicationStageTransitions.__table__.alias("hire")
|
||||
days=_days_expr(hire.c.valid_from,entry.c.valid_from)
|
||||
statement=(
|
||||
select(func.avg(days))
|
||||
.select_from(
|
||||
hire.join(
|
||||
entry,
|
||||
and_(
|
||||
hire.c.inbox_id==entry.c.inbox_id,
|
||||
entry.c.from_stage.is_(None),
|
||||
),
|
||||
)
|
||||
)
|
||||
.where(hire.c.to_stage==Candidate_application_Status.HIRED.value)
|
||||
)
|
||||
if from_date is not None:
|
||||
statement=statement.where(hire.c.valid_from>=from_date)
|
||||
if to_date is not None:
|
||||
statement=statement.where(hire.c.valid_from<to_date)
|
||||
if department or recruiter_id:
|
||||
statement=(
|
||||
statement
|
||||
.outerjoin(Inbox,hire.c.inbox_id==Inbox.id)
|
||||
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
||||
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||
)
|
||||
if department:
|
||||
statement=statement.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement=statement.where(
|
||||
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
||||
)
|
||||
result=await self.session.execute(statement)
|
||||
value=result.scalar_one()
|
||||
return float(value) if value is not None else None
|
||||
|
||||
async def _avg_time_to_fill(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
days=_days_expr(JobPosts.closed_at,JobPosts.created_at)
|
||||
statement=select(func.avg(days)).select_from(JobPosts).where(
|
||||
JobPosts.is_deleted==False, # noqa: E712
|
||||
JobPosts.requisition_status=="closed",
|
||||
JobPosts.closed_at.is_not(None),
|
||||
)
|
||||
if from_date is not None:
|
||||
statement=statement.where(JobPosts.closed_at>=from_date)
|
||||
if to_date is not None:
|
||||
statement=statement.where(JobPosts.closed_at<to_date)
|
||||
if department:
|
||||
statement=statement.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement=statement.where(JobPosts.current_recruiter_id==rid)
|
||||
result=await self.session.execute(statement)
|
||||
value=result.scalar_one()
|
||||
return float(value) if value is not None else None
|
||||
|
||||
async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
if not hires:
|
||||
return None
|
||||
statement=select(func.coalesce(func.sum(HiringCosts.amount),0.0))
|
||||
if from_date is not None:
|
||||
statement=statement.where(HiringCosts.incurred_at>=from_date)
|
||||
if to_date is not None:
|
||||
statement=statement.where(HiringCosts.incurred_at<to_date)
|
||||
if department or recruiter_id:
|
||||
statement=statement.outerjoin(JobPosts,HiringCosts.job_post_id==JobPosts.id)
|
||||
if department:
|
||||
statement=statement.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement=statement.where(JobPosts.current_recruiter_id==rid)
|
||||
result=await self.session.execute(statement)
|
||||
total=float(result.scalar_one() or 0.0)
|
||||
total=await HiringCosts.sum_amount(
|
||||
self.session,from_date=from_date,to_date=to_date,
|
||||
department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
return total/hires
|
||||
|
||||
async def get_kpis(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
|
|
@ -297,58 +96,72 @@ class Analytics:
|
|||
today_start=datetime(now.year,now.month,now.day,tzinfo=timezone.utc)
|
||||
tomorrow=today_start+timedelta(days=1)
|
||||
|
||||
open_jobs=await self._count_jobs("open",department=department,recruiter_id=recruiter_id)
|
||||
open_jobs_prior=await self._count_open_snapshot(window_from,department=department,recruiter_id=recruiter_id)
|
||||
|
||||
closed_jobs=await self._count_jobs(
|
||||
"closed",window_from,window_to,department,recruiter_id,closed_in_window=True
|
||||
open_jobs=await JobPosts.count_requisitions(
|
||||
self.session,status="open",department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
closed_jobs_prior=await self._count_jobs(
|
||||
"closed",prior_from,prior_to,department,recruiter_id,closed_in_window=True
|
||||
open_jobs_prior=await JobPosts.count_open_snapshot(
|
||||
self.session,window_from,department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
|
||||
total_candidates=await self._count_candidates(window_from,window_to,department,recruiter_id)
|
||||
total_candidates_prior=await self._count_candidates(prior_from,prior_to,department,recruiter_id)
|
||||
|
||||
interviews_today_q=select(func.count()).select_from(Interviews).where(
|
||||
Interviews.interview_date>=today_start,
|
||||
Interviews.interview_date<tomorrow,
|
||||
closed_jobs=await JobPosts.count_requisitions(
|
||||
self.session,status="closed",department=department,recruiter_id=recruiter_id,
|
||||
from_date=window_from,to_date=window_to,closed_in_window=True,
|
||||
)
|
||||
interviews_today=int((await self.session.execute(interviews_today_q)).scalar_one() or 0)
|
||||
|
||||
upcoming_q=select(func.count()).select_from(Interviews).where(
|
||||
Interviews.interview_status.ilike("scheduled"),
|
||||
Interviews.interview_date>=now,
|
||||
closed_jobs_prior=await JobPosts.count_requisitions(
|
||||
self.session,status="closed",department=department,recruiter_id=recruiter_id,
|
||||
from_date=prior_from,to_date=prior_to,closed_in_window=True,
|
||||
)
|
||||
interviews_upcoming=int((await self.session.execute(upcoming_q)).scalar_one() or 0)
|
||||
|
||||
next_q=select(func.min(func.coalesce(Interviews.interview_time,Interviews.interview_date))).where(
|
||||
Interviews.interview_status.ilike("scheduled"),
|
||||
Interviews.interview_date>=now,
|
||||
total_candidates=await Inbox.count_in_window(
|
||||
self.session,window_from,window_to,department,recruiter_id,
|
||||
)
|
||||
total_candidates_prior=await Inbox.count_in_window(
|
||||
self.session,prior_from,prior_to,department,recruiter_id,
|
||||
)
|
||||
|
||||
interviews_today=await Interviews.count_between(
|
||||
self.session,today_start,tomorrow,recruiter_id=recruiter_id,
|
||||
)
|
||||
interviews_upcoming=await Interviews.count_upcoming(
|
||||
self.session,now,recruiter_id=recruiter_id,
|
||||
)
|
||||
next_at=await Interviews.next_scheduled_at(
|
||||
self.session,now,recruiter_id=recruiter_id,
|
||||
)
|
||||
next_at=(await self.session.execute(next_q)).scalar_one()
|
||||
next_interview_at=next_at.isoformat() if next_at else None
|
||||
|
||||
offers_accepted=await self._count_offers(["accepted"],window_from,window_to,department,recruiter_id)
|
||||
offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id)
|
||||
offers_sent=await self._count_offers(
|
||||
["sent","negotiating","accepted","declined","expired"],
|
||||
window_from,window_to,department,recruiter_id,exclude_draft=True,
|
||||
offers_accepted=await Offers.count_in_window(
|
||||
self.session,["accepted"],window_from,window_to,department,recruiter_id,
|
||||
)
|
||||
offers_sent_prior=await self._count_offers(
|
||||
["sent","negotiating","accepted","declined","expired"],
|
||||
prior_from,prior_to,department,recruiter_id,exclude_draft=True,
|
||||
offers_accepted_prior=await Offers.count_in_window(
|
||||
self.session,["accepted"],prior_from,prior_to,department,recruiter_id,
|
||||
)
|
||||
offers_sent=await Offers.count_in_window(
|
||||
self.session,None,window_from,window_to,department,recruiter_id,exclude_draft=True,
|
||||
)
|
||||
offers_sent_prior=await Offers.count_in_window(
|
||||
self.session,None,prior_from,prior_to,department,recruiter_id,exclude_draft=True,
|
||||
)
|
||||
|
||||
hires=await self._count_hires(window_from,window_to,department,recruiter_id)
|
||||
hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id)
|
||||
|
||||
time_to_hire=await self._avg_time_to_hire(window_from,window_to,department,recruiter_id)
|
||||
time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id)
|
||||
time_to_fill=await self._avg_time_to_fill(window_from,window_to,department,recruiter_id)
|
||||
time_to_fill_prior=await self._avg_time_to_fill(prior_from,prior_to,department,recruiter_id)
|
||||
time_to_hire=await ApplicationStageTransitions.avg_time_to_hire(
|
||||
self.session,window_from,window_to,department,recruiter_id,
|
||||
)
|
||||
time_to_hire_prior=await ApplicationStageTransitions.avg_time_to_hire(
|
||||
self.session,prior_from,prior_to,department,recruiter_id,
|
||||
)
|
||||
time_to_fill=await JobPosts.avg_time_to_fill(
|
||||
self.session,window_from,window_to,department,recruiter_id,
|
||||
)
|
||||
time_to_fill_prior=await JobPosts.avg_time_to_fill(
|
||||
self.session,prior_from,prior_to,department,recruiter_id,
|
||||
)
|
||||
cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id)
|
||||
cost_per_hire_prior=await self._cost_per_hire(hires_prior,prior_from,prior_to,department,recruiter_id)
|
||||
cost_per_hire_prior=await self._cost_per_hire(
|
||||
hires_prior,prior_from,prior_to,department,recruiter_id,
|
||||
)
|
||||
|
||||
# REQ-ANL-08: the time-to-hire baseline is an org setting with provenance
|
||||
# ({"days": N, "source": "..."}), never a constant — OPEN-12 flags the BRD's
|
||||
|
|
@ -397,28 +210,27 @@ class Analytics:
|
|||
}
|
||||
|
||||
async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
statement=select(
|
||||
Inbox_Messages.application_status,
|
||||
func.count().label("count"),
|
||||
).select_from(Inbox_Messages)
|
||||
if department or recruiter_id:
|
||||
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||
if department:
|
||||
statement=statement.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement=statement.where(
|
||||
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
||||
)
|
||||
if from_date is not None or to_date is not None:
|
||||
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id)
|
||||
if from_date is not None:
|
||||
statement=statement.where(Inbox.created_at>=from_date)
|
||||
if to_date is not None:
|
||||
statement=statement.where(Inbox.created_at<to_date)
|
||||
statement=statement.group_by(Inbox_Messages.application_status)
|
||||
result=await self.session.execute(statement)
|
||||
counts={str(row[0].value if hasattr(row[0],"value") else row[0]): int(row[1] or 0) for row in result.all()}
|
||||
# Same two sources the pipeline board counts: inbox applications on an
|
||||
# assigned job, plus manual-upload candidates. Counting only
|
||||
# inbox_messages left Add Candidate rows (and anyone dragged to
|
||||
# Interview there) invisible on the dashboard doughnut.
|
||||
inbox_counts=await Inbox_Messages.counts_by_application_status(
|
||||
self.session,from_date=from_date,to_date=to_date,
|
||||
department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
manual_counts=await Manual_UPLOAD_CANDIDATE.counts_by_application_status(
|
||||
self.session,from_date=from_date,to_date=to_date,
|
||||
department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
counts={stage.value:0 for stage in Candidate_application_Status}
|
||||
pending=Candidate_application_Status.PENDING.value
|
||||
for src in (inbox_counts,manual_counts):
|
||||
for key,n in src.items():
|
||||
n=int(n or 0)
|
||||
if key in counts:
|
||||
counts[key]+=n
|
||||
else:
|
||||
counts[pending]+=n
|
||||
return [
|
||||
serialize_stage_count(stage.value,counts.get(stage.value,0))
|
||||
for stage in Candidate_application_Status
|
||||
|
|
@ -428,65 +240,20 @@ class Analytics:
|
|||
months=max(1,int(months or 7))
|
||||
now=datetime.now(timezone.utc)
|
||||
start=_month_start(now)
|
||||
# Walk back (months-1) months
|
||||
for _ in range(months-1):
|
||||
start=_month_start(start-timedelta(days=1))
|
||||
|
||||
month_bucket=func.date_trunc("month",Inbox.created_at)
|
||||
apps_q=(
|
||||
select(month_bucket.label("month"),func.count().label("count"))
|
||||
.select_from(Inbox)
|
||||
.where(Inbox.created_at>=start)
|
||||
.group_by(month_bucket)
|
||||
.order_by(month_bucket)
|
||||
)
|
||||
if department or recruiter_id:
|
||||
apps_q=(
|
||||
apps_q
|
||||
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
||||
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||
)
|
||||
if department:
|
||||
apps_q=apps_q.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
apps_q=apps_q.where(
|
||||
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
||||
)
|
||||
apps_rows=await self.session.execute(apps_q)
|
||||
apps_map={}
|
||||
for month,count in apps_rows.all():
|
||||
apps_map[_month_key(month)]=int(count or 0)
|
||||
for month,count in await Inbox.counts_by_month(
|
||||
self.session,start,department=department,recruiter_id=recruiter_id,
|
||||
):
|
||||
apps_map[_month_key(month)]=count
|
||||
|
||||
hire_bucket=func.date_trunc("month",ApplicationStageTransitions.valid_from)
|
||||
hires_q=(
|
||||
select(hire_bucket.label("month"),func.count().label("count"))
|
||||
.select_from(ApplicationStageTransitions)
|
||||
.where(
|
||||
ApplicationStageTransitions.to_stage==Candidate_application_Status.HIRED.value,
|
||||
ApplicationStageTransitions.valid_from>=start,
|
||||
)
|
||||
.group_by(hire_bucket)
|
||||
.order_by(hire_bucket)
|
||||
)
|
||||
if department or recruiter_id:
|
||||
hires_q=(
|
||||
hires_q
|
||||
.outerjoin(Inbox,ApplicationStageTransitions.inbox_id==Inbox.id)
|
||||
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
||||
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||
)
|
||||
if department:
|
||||
hires_q=hires_q.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
hires_q=hires_q.where(
|
||||
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
||||
)
|
||||
hire_rows=await self.session.execute(hires_q)
|
||||
hire_map={}
|
||||
for month,count in hire_rows.all():
|
||||
hire_map[_month_key(month)]=int(count or 0)
|
||||
for month,count in await ApplicationStageTransitions.counts_hires_by_month(
|
||||
self.session,start,department=department,recruiter_id=recruiter_id,
|
||||
):
|
||||
hire_map[_month_key(month)]=count
|
||||
|
||||
labels=[]
|
||||
applications=[]
|
||||
|
|
@ -500,57 +267,18 @@ class Analytics:
|
|||
return {"labels": labels,"applications": applications,"hires": hires}
|
||||
|
||||
async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
statement=(
|
||||
select(
|
||||
SourceChannels.id.label("source_id"),
|
||||
func.coalesce(SourceChannels.label,"Unknown").label("source"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.select_from(Inbox_Messages)
|
||||
.outerjoin(SourceChannels,Inbox_Messages.source_channel_id==SourceChannels.id)
|
||||
rows=await Inbox_Messages.counts_by_source(
|
||||
self.session,from_date=from_date,to_date=to_date,
|
||||
department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
if department or recruiter_id:
|
||||
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||
if department:
|
||||
statement=statement.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement=statement.where(
|
||||
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
||||
)
|
||||
if from_date is not None or to_date is not None:
|
||||
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id)
|
||||
if from_date is not None:
|
||||
statement=statement.where(Inbox.created_at>=from_date)
|
||||
if to_date is not None:
|
||||
statement=statement.where(Inbox.created_at<to_date)
|
||||
statement=statement.group_by(SourceChannels.id,SourceChannels.label).order_by(func.count().desc())
|
||||
result=await self.session.execute(statement)
|
||||
rows=result.all()
|
||||
|
||||
# REQ-ANL-09 cost side: spend explicitly tagged to a source channel in the
|
||||
# cost ledger. Untagged spend is deliberately excluded — it belongs to
|
||||
# cost-per-hire, and folding it into "Unknown" would fabricate a ROI figure.
|
||||
spend_q=select(
|
||||
HiringCosts.source_channel_id,
|
||||
func.coalesce(func.sum(HiringCosts.amount),0.0),
|
||||
).where(HiringCosts.source_channel_id.is_not(None))
|
||||
if from_date is not None:
|
||||
spend_q=spend_q.where(HiringCosts.incurred_at>=from_date)
|
||||
if to_date is not None:
|
||||
spend_q=spend_q.where(HiringCosts.incurred_at<to_date)
|
||||
if department or recruiter_id:
|
||||
spend_q=spend_q.outerjoin(JobPosts,HiringCosts.job_post_id==JobPosts.id)
|
||||
if department:
|
||||
spend_q=spend_q.where(JobPosts.department==department)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
spend_q=spend_q.where(JobPosts.current_recruiter_id==rid)
|
||||
spend_q=spend_q.group_by(HiringCosts.source_channel_id)
|
||||
spend_map={
|
||||
channel_id:float(total or 0.0)
|
||||
for channel_id,total in (await self.session.execute(spend_q)).all()
|
||||
}
|
||||
spend_map=await HiringCosts.sum_by_source_channel(
|
||||
self.session,from_date=from_date,to_date=to_date,
|
||||
department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
|
||||
# A channel with tagged spend but zero applications must still get a row:
|
||||
# spend that produced nothing is the strongest ROI signal this table has,
|
||||
|
|
@ -558,9 +286,9 @@ class Analytics:
|
|||
present={source_id for source_id,_,_ in rows}
|
||||
missing=[cid for cid in spend_map if cid not in present]
|
||||
if missing:
|
||||
channels_q=select(SourceChannels.id,SourceChannels.label).where(SourceChannels.id.in_(missing))
|
||||
rows=list(rows)+[
|
||||
(cid,label,0) for cid,label in (await self.session.execute(channels_q)).all()
|
||||
(cid,label,0)
|
||||
for cid,label in await SourceChannels.labels_by_ids(self.session,missing)
|
||||
]
|
||||
|
||||
return [
|
||||
|
|
@ -572,52 +300,31 @@ class Analytics:
|
|||
|
||||
async def get_recruiter_performance(self,top=5,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
top=max(1,int(top or 5))
|
||||
recruiters_q=(
|
||||
select(Users)
|
||||
.join(Roles,Users.role_id==Roles.id)
|
||||
.where(
|
||||
Roles.role_name==EnumRoles.RECRUITER.value,
|
||||
Users.is_deleted==False, # noqa: E712
|
||||
)
|
||||
recruiters=await Users.list_by_role_name(
|
||||
self.session,EnumRoles.RECRUITER.value,user_id=recruiter_id,
|
||||
)
|
||||
rid=_as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
recruiters_q=recruiters_q.where(Users.id==rid)
|
||||
recruiters=list((await self.session.execute(recruiters_q)).scalars().all())
|
||||
|
||||
rows=[]
|
||||
for user in recruiters:
|
||||
hires_q=select(func.count()).select_from(Inbox_Messages).where(
|
||||
Inbox_Messages.recruiter_id==user.id,
|
||||
Inbox_Messages.application_status==Candidate_application_Status.HIRED,
|
||||
hires=await Inbox_Messages.count_hires_by_recruiter(
|
||||
self.session,user.id,from_date=from_date,to_date=to_date,department=department,
|
||||
)
|
||||
if department:
|
||||
hires_q=hires_q.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id).where(
|
||||
JobPosts.department==department
|
||||
)
|
||||
if from_date is not None or to_date is not None:
|
||||
hires_q=hires_q.join(Inbox,Inbox.message_id==Inbox_Messages.id)
|
||||
if from_date is not None:
|
||||
hires_q=hires_q.where(Inbox.created_at>=from_date)
|
||||
if to_date is not None:
|
||||
hires_q=hires_q.where(Inbox.created_at<to_date)
|
||||
hires=int((await self.session.execute(hires_q)).scalar_one() or 0)
|
||||
|
||||
open_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id)
|
||||
open_posts_q=select(func.count()).select_from(JobPosts).where(
|
||||
JobPosts.current_recruiter_id==user.id,
|
||||
JobPosts.requisition_status=="open",
|
||||
JobPosts.is_deleted==False, # noqa: E712
|
||||
open_posts=await JobPosts.count_by_current_recruiter(
|
||||
self.session,user.id,status=RequisitionStatus.OPEN.value,department=department,
|
||||
)
|
||||
if department:
|
||||
open_posts_q=open_posts_q.where(JobPosts.department==department)
|
||||
open_posts=int((await self.session.execute(open_posts_q)).scalar_one() or 0)
|
||||
open_reqs=max(open_assign,open_posts)
|
||||
|
||||
avg_tth=await self._avg_time_to_hire(
|
||||
from_date,to_date,department,recruiter_id=str(user.id)
|
||||
completed=await JobPosts.count_by_current_recruiter(
|
||||
self.session,user.id,status=RequisitionStatus.COMPLETED.value,
|
||||
department=department,from_date=from_date,to_date=to_date,
|
||||
)
|
||||
rows.append(serialize_recruiter_row(user.id,user.name,hires,open_reqs,avg_tth))
|
||||
avg_tth=await ApplicationStageTransitions.avg_time_to_hire(
|
||||
self.session,from_date=from_date,to_date=to_date,
|
||||
department=department,recruiter_id=str(user.id),
|
||||
)
|
||||
rows.append(serialize_recruiter_row(
|
||||
user.id,user.name,hires,open_reqs,avg_tth,completed=completed,
|
||||
))
|
||||
|
||||
rows.sort(key=lambda r: r["hires"],reverse=True)
|
||||
rows.sort(key=lambda r: (r["completed"], r["hires"]),reverse=True)
|
||||
return rows[:top]
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ from datetime import timezone
|
|||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from assessments.models import Assessments, _now
|
||||
from assessments.serializers import serialize_assessment
|
||||
|
|
@ -82,12 +80,7 @@ class Assessment:
|
|||
|
||||
inbox_by_id = {}
|
||||
if inbox_ids:
|
||||
result = await self.session.execute(
|
||||
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()}
|
||||
inbox_by_id = {row.id: row for row in await Inbox.get_by_ids(self.session, inbox_ids)}
|
||||
for row in inbox_by_id.values():
|
||||
msg = row.messages
|
||||
if msg is not None and msg.assigned_job_post_id:
|
||||
|
|
@ -95,10 +88,9 @@ class Assessment:
|
|||
|
||||
manual_by_id = {}
|
||||
if manual_ids:
|
||||
result = await self.session.execute(
|
||||
select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids))
|
||||
)
|
||||
manual_by_id = {row.id: row for row in result.scalars().all()}
|
||||
manual_by_id = {
|
||||
row.id: row for row in await Manual_UPLOAD_CANDIDATE.get_by_ids(self.session, manual_ids)
|
||||
}
|
||||
for row in manual_by_id.values():
|
||||
if row.job_post_id:
|
||||
job_ids.append(row.job_post_id)
|
||||
|
|
@ -106,8 +98,9 @@ class Assessment:
|
|||
jobs_by_id = {}
|
||||
uids = [j for j in set(job_ids) if j]
|
||||
if uids:
|
||||
result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids)))
|
||||
jobs_by_id = {row.id: row for row in result.scalars().all()}
|
||||
jobs_by_id = {
|
||||
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
|
||||
|
||||
def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id):
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import uuid
|
|||
from datetime import timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from candidate_forms.models import CandidateForms, _now
|
||||
from candidate_forms.plugins import (
|
||||
|
|
@ -138,12 +136,7 @@ class CandidateForm:
|
|||
|
||||
inbox_by_id = {}
|
||||
if inbox_ids:
|
||||
result = await self.session.execute(
|
||||
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()}
|
||||
inbox_by_id = {row.id: row for row in await Inbox.get_by_ids(self.session, inbox_ids)}
|
||||
for row in inbox_by_id.values():
|
||||
msg = row.messages
|
||||
if msg is not None and msg.assigned_job_post_id:
|
||||
|
|
@ -151,10 +144,9 @@ class CandidateForm:
|
|||
|
||||
manual_by_id = {}
|
||||
if manual_ids:
|
||||
result = await self.session.execute(
|
||||
select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids))
|
||||
)
|
||||
manual_by_id = {row.id: row for row in result.scalars().all()}
|
||||
manual_by_id = {
|
||||
row.id: row for row in await Manual_UPLOAD_CANDIDATE.get_by_ids(self.session, manual_ids)
|
||||
}
|
||||
for row in manual_by_id.values():
|
||||
if row.job_post_id:
|
||||
job_ids.append(row.job_post_id)
|
||||
|
|
@ -162,17 +154,13 @@ class CandidateForm:
|
|||
jobs_by_id = {}
|
||||
uids = [j for j in set(job_ids) if j]
|
||||
if uids:
|
||||
result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids)))
|
||||
jobs_by_id = {row.id: row for row in result.scalars().all()}
|
||||
jobs_by_id = {
|
||||
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.created_by for r in rows if r.created_by}
|
||||
users_by_id = {}
|
||||
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()}
|
||||
users_by_id = await Users.names_by_ids(self.session, user_ids)
|
||||
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):
|
||||
|
|
@ -210,8 +198,8 @@ class CandidateForm:
|
|||
row,
|
||||
candidate_name=name,
|
||||
job_title=title,
|
||||
interviewer_name=users_by_id.get(row.interviewer_id),
|
||||
created_by_name=users_by_id.get(row.created_by),
|
||||
interviewer_name=users_by_id.get(str(row.interviewer_id)) if row.interviewer_id else None,
|
||||
created_by_name=users_by_id.get(str(row.created_by)) if row.created_by else None,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ def clamp_in_resume(key,sentinel):
|
|||
|
||||
|
||||
def _clean_linkedin(value,resume_text):
|
||||
"""Keep a LinkedIn URL only when the CV evidences it. Sentinel / invented → None."""
|
||||
url=(value or "").strip()
|
||||
if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"):
|
||||
return None
|
||||
|
|
@ -70,7 +71,19 @@ def _clean_linkedin(value,resume_text):
|
|||
return None
|
||||
if not lowered.startswith("http://") and not lowered.startswith("https://"):
|
||||
url="https://"+url.lstrip("/")
|
||||
return url
|
||||
text=(resume_text or "").strip()
|
||||
if not text:
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- 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}
|
||||
- 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):
|
||||
- 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:
|
||||
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})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -416,6 +416,13 @@ class SheetImportRun(SQLModel, table=True):
|
|||
)
|
||||
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
|
||||
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
|
||||
row = cls(**fields)
|
||||
|
|
|
|||
|
|
@ -319,11 +319,7 @@ class SheetImport(SheetRead):
|
|||
row=await SheetImportRun.get_active(session)
|
||||
if row:
|
||||
return serialize_import_run(row)
|
||||
from sqlmodel import select
|
||||
result=await session.execute(
|
||||
select(SheetImportRun).order_by(SheetImportRun.created_at.desc()).limit(1)
|
||||
)
|
||||
row=result.scalars().first()
|
||||
row=await SheetImportRun.get_latest(session)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="No import runs yet")
|
||||
return serialize_import_run(row)
|
||||
|
|
@ -411,7 +407,7 @@ class SheetFormData(Sheet):
|
|||
await self._promote_to_application(updated)
|
||||
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")
|
||||
if processing_state not in 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 not row.job_post_id:
|
||||
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)
|
||||
if not updated:
|
||||
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
|
||||
assigned: bool | None = None
|
||||
is_duplicate: bool | None = None
|
||||
processing_state: str | None = None
|
||||
|
||||
|
||||
class TriageOverrideBody(BaseModel):
|
||||
|
|
@ -251,6 +252,7 @@ async def mark_all_inbox_read(
|
|||
application_status=payload.application_status,
|
||||
assigned=payload.assigned,
|
||||
is_duplicate=payload.is_duplicate,
|
||||
processing_state=payload.processing_state,
|
||||
)
|
||||
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
@ -283,6 +285,7 @@ async def get_all_applications(
|
|||
assigned: bool | None = Query(default=None),
|
||||
is_duplicate: bool | None = Query(default=None),
|
||||
no_suggestions: bool | None = Query(default=None),
|
||||
processing_state: str | None = Query(default=None),
|
||||
search: str | None = Query(None),
|
||||
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
|
||||
top: int | None = Query(None, ge=1, le=500),
|
||||
|
|
@ -293,20 +296,20 @@ async def get_all_applications(
|
|||
try:
|
||||
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):
|
||||
items=await service.get_all_applications(top, skip, 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)
|
||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
||||
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
||||
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||
if isread==False:
|
||||
items=await service.get_all_applications(top, skip, 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)
|
||||
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
||||
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||
if record_id:
|
||||
item=await service.get_application_by_id(record_id)
|
||||
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)
|
||||
total=await service.count_inbox_messages(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,processing_state=processing_state)
|
||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -392,7 +395,7 @@ async def set_processing_state(
|
|||
):
|
||||
try:
|
||||
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})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ class Inbox(SQLModel, table=True):
|
|||
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||
.outerjoin(AtsResults,cls.ats_id==AtsResults.id)
|
||||
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
||||
.where(Inbox_Messages.attachment==True) # noqa: E712
|
||||
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
||||
# Newest-first is the list contract; score is only a tiebreak
|
||||
# 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(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
||||
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
||||
.where(Inbox_Messages.attachment==True) # noqa: E712
|
||||
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
||||
.group_by(Inbox_Messages.application_status)
|
||||
)
|
||||
|
|
@ -259,6 +261,64 @@ class Inbox(SQLModel, table=True):
|
|||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@classmethod
|
||||
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
|
||||
"""Optional department / recruiter via inbox_messages → job_posts."""
|
||||
if not department and not recruiter_id:
|
||||
return statement
|
||||
from job.job_post.models import JobPosts
|
||||
statement = (
|
||||
statement
|
||||
.outerjoin(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
.outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id)
|
||||
)
|
||||
if department:
|
||||
statement = statement.where(JobPosts.department == department)
|
||||
try:
|
||||
rid = uuid.UUID(str(recruiter_id)) if recruiter_id not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
rid = None
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
async def count_in_window(
|
||||
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||
):
|
||||
statement = (
|
||||
select(func.count())
|
||||
.select_from(cls)
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.join(Roles, Users.role_id == Roles.id)
|
||||
.where(Roles.role_name == EnumRoles.CANDIDATE.value)
|
||||
)
|
||||
if from_date is not None:
|
||||
statement = statement.where(cls.created_at >= from_date)
|
||||
if to_date is not None:
|
||||
statement = statement.where(cls.created_at < to_date)
|
||||
statement = cls.scoped_to_job(statement, department, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
@classmethod
|
||||
async def counts_by_month(
|
||||
cls, session: AsyncSession, start, department=None, recruiter_id=None,
|
||||
):
|
||||
month_bucket = func.date_trunc("month", cls.created_at)
|
||||
statement = (
|
||||
select(month_bucket.label("month"), func.count().label("count"))
|
||||
.select_from(cls)
|
||||
.where(cls.created_at >= start)
|
||||
.group_by(month_bucket)
|
||||
.order_by(month_bucket)
|
||||
)
|
||||
statement = cls.scoped_to_job(statement, department, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return [(month, int(count or 0)) for month, count in result.all()]
|
||||
|
||||
@classmethod
|
||||
async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None):
|
||||
if record_id is None:
|
||||
|
|
@ -270,6 +330,23 @@ class Inbox(SQLModel, table=True):
|
|||
result=await session.execute(select(cls).where(cls.id==iid))
|
||||
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
|
||||
async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None):
|
||||
"""Inbox row with `messages` selectin-loaded for stage / application writers."""
|
||||
|
|
@ -700,6 +777,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
assigned: bool | None=None,
|
||||
is_duplicate: 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.
|
||||
|
||||
|
|
@ -729,11 +807,16 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
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
|
||||
|
||||
@classmethod
|
||||
async def get_inbox_messages(
|
||||
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None
|
||||
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-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(
|
||||
select(cls).order_by(cls.created_at.desc()),
|
||||
search, isread, application_status, assigned, is_duplicate,
|
||||
no_suggestions,
|
||||
no_suggestions, processing_state,
|
||||
)
|
||||
if 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()}
|
||||
|
||||
@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(
|
||||
select(func.count()).select_from(cls),
|
||||
search, isread, application_status, assigned, is_duplicate,
|
||||
no_suggestions,
|
||||
no_suggestions, processing_state,
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
|
@ -917,6 +1000,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
application_status: Candidate_application_Status=Candidate_application_Status.CLOSED,
|
||||
assigned: bool | None=None,
|
||||
is_duplicate: bool | None=None,
|
||||
processing_state: str | None=None,
|
||||
) -> int:
|
||||
"""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
|
||||
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))
|
||||
result=await session.execute(
|
||||
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.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.application_status == Candidate_application_Status.PROCESS, 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 == "processed", 1), else_=0)), 0).label("processed"),
|
||||
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
|
||||
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
|
||||
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"),
|
||||
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"),
|
||||
)
|
||||
).where(cls.attachment == True) # noqa: E712
|
||||
row = (await session.execute(statement)).one()
|
||||
return {
|
||||
"all": int(row.all_count or 0),
|
||||
|
|
@ -963,6 +1047,15 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
if not row:
|
||||
return None
|
||||
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)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
|
@ -979,6 +1072,145 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def count_hires_by_recruiter(
|
||||
cls, session: AsyncSession, recruiter_id, *, from_date=None, to_date=None, department=None,
|
||||
):
|
||||
"""Messages currently HIRED and assigned to this recruiter_id."""
|
||||
try:
|
||||
uid = uuid.UUID(str(recruiter_id))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
statement = select(func.count()).select_from(cls).where(
|
||||
cls.recruiter_id == uid,
|
||||
cls.application_status == Candidate_application_Status.HIRED,
|
||||
)
|
||||
if department:
|
||||
statement = statement.outerjoin(JobPosts, cls.assigned_job_post_id == JobPosts.id).where(
|
||||
JobPosts.department == department
|
||||
)
|
||||
if from_date is not None or to_date is not None:
|
||||
statement = statement.join(Inbox, Inbox.message_id == cls.id)
|
||||
if from_date is not None:
|
||||
statement = statement.where(Inbox.created_at >= from_date)
|
||||
if to_date is not None:
|
||||
statement = statement.where(Inbox.created_at < to_date)
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
@classmethod
|
||||
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
|
||||
"""Optional department / recruiter via assigned job post."""
|
||||
if not department and not recruiter_id:
|
||||
return statement
|
||||
from job.job_post.models import JobPosts
|
||||
statement = statement.outerjoin(JobPosts, cls.assigned_job_post_id == JobPosts.id)
|
||||
if department:
|
||||
statement = statement.where(JobPosts.department == department)
|
||||
try:
|
||||
rid = uuid.UUID(str(recruiter_id)) if recruiter_id not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
rid = None
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
def _window_by_inbox(cls, statement, from_date=None, to_date=None):
|
||||
if from_date is None and to_date is None:
|
||||
return statement
|
||||
statement = statement.join(Inbox, Inbox.message_id == cls.id)
|
||||
if from_date is not None:
|
||||
statement = statement.where(Inbox.created_at >= from_date)
|
||||
if to_date is not None:
|
||||
statement = statement.where(Inbox.created_at < to_date)
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
async def count_hired(
|
||||
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||
):
|
||||
"""Messages currently HIRED, windowed on inbox.created_at."""
|
||||
statement = (
|
||||
select(func.count())
|
||||
.select_from(cls)
|
||||
.join(Inbox, Inbox.message_id == cls.id)
|
||||
.where(cls.application_status == Candidate_application_Status.HIRED)
|
||||
)
|
||||
if from_date is not None:
|
||||
statement = statement.where(Inbox.created_at >= from_date)
|
||||
if to_date is not None:
|
||||
statement = statement.where(Inbox.created_at < to_date)
|
||||
statement = cls.scoped_to_job(statement, department, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
@classmethod
|
||||
async def counts_by_application_status(
|
||||
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||
):
|
||||
"""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):
|
||||
"""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())
|
||||
|
||||
@classmethod
|
||||
async def labels_by_ids(cls, session: AsyncSession, ids):
|
||||
keys = [cid for cid in (ids or []) if cid is not None]
|
||||
if not keys:
|
||||
return []
|
||||
result = await session.execute(select(cls.id, cls.label).where(cls.id.in_(keys)))
|
||||
return [(cid, label) for cid, label in result.all()]
|
||||
|
||||
|
||||
class AtsResults(SQLModel, table=True):
|
||||
__tablename__ = "ats_results"
|
||||
|
|
@ -1209,6 +1449,18 @@ class AtsResults(SQLModel, table=True):
|
|||
)
|
||||
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
|
||||
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.
|
||||
|
|
@ -1357,6 +1609,13 @@ class MailboxSyncRun(SQLModel, table=True):
|
|||
)
|
||||
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
|
||||
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
|
||||
row = cls(**fields)
|
||||
|
|
|
|||
|
|
@ -273,13 +273,13 @@ class Email:
|
|||
item["assigned_job_post"]=None
|
||||
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):
|
||||
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):
|
||||
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)
|
||||
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) or processing_state:
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
||||
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:
|
||||
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])
|
||||
return [serialize_application(m,linkedin_url=urls.get(m.id)) for m in messages]
|
||||
|
||||
|
|
@ -351,11 +351,7 @@ class Email:
|
|||
if row:
|
||||
return serialize_mailbox_sync_run(row)
|
||||
# Latest finished run so the UI can still show the last result after refresh.
|
||||
from sqlmodel import select
|
||||
result=await self.session.execute(
|
||||
select(MailboxSyncRun).order_by(MailboxSyncRun.created_at.desc()).limit(1)
|
||||
)
|
||||
row=result.scalars().first()
|
||||
row=await MailboxSyncRun.get_latest(self.session)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="No sync runs yet")
|
||||
return serialize_mailbox_sync_run(row)
|
||||
|
|
@ -431,13 +427,13 @@ class Email:
|
|||
results.append({"email":email,"sent":False})
|
||||
return results
|
||||
|
||||
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None):
|
||||
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
|
||||
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 or processing_state:
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
||||
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:
|
||||
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):
|
||||
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,
|
||||
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.
|
||||
|
||||
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(
|
||||
self.session,read,search=search,isread=isread,
|
||||
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",
|
||||
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)
|
||||
|
|
@ -531,10 +528,33 @@ class Email:
|
|||
async def get_counts(self):
|
||||
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")
|
||||
if processing_state not in 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)
|
||||
if not message:
|
||||
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 import HTTPException
|
||||
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.notes.views import Note
|
||||
from job.activity.views import ActivityLog
|
||||
|
|
@ -33,6 +33,11 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
class MatchingAssign(BaseModel):
|
||||
id: UUID
|
||||
job_post_id: UUID | None = None
|
||||
|
||||
|
||||
class CandidateUpdate(BaseModel):
|
||||
favorite: bool | None = None
|
||||
rating: float | None = None
|
||||
|
|
@ -133,6 +138,8 @@ class JobUpdate(BaseModel):
|
|||
experience_min: int | None = None
|
||||
experience_max: int | None = None
|
||||
description: str | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
hiring_manager_id: UUID | None = None
|
||||
|
||||
|
||||
class JobStatusUpdate(BaseModel):
|
||||
|
|
@ -297,6 +304,7 @@ async def cv_bank_upload(
|
|||
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
|
||||
text=parsed.get("text") or ""
|
||||
detected,_=extract_candidate_email(text)
|
||||
parsed_linkedin=await parse_linkedin_url_from_cv(text)
|
||||
# Basename against both separator styles — a Windows client sends
|
||||
# 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"
|
||||
|
|
@ -308,6 +316,7 @@ async def cv_bank_upload(
|
|||
file_name=original,
|
||||
created_by=current_user.get("id"),
|
||||
pdf_bytes=content,
|
||||
linkedin_url=parsed_linkedin,
|
||||
)
|
||||
try:
|
||||
uploaded=S3().upload_for_record(
|
||||
|
|
@ -332,6 +341,7 @@ async def cv_bank_upload(
|
|||
"file_name":row.file_name,
|
||||
"file_path":row.file_path 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,
|
||||
},"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
@ -359,6 +369,7 @@ async def cv_bank_fetch(
|
|||
"file_path":(r.file_path or "").strip() or None,
|
||||
"candidate_email":r.candidate_email 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,
|
||||
} for r in rows]
|
||||
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))
|
||||
|
||||
|
||||
@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")
|
||||
async def candidate_inbox_match(
|
||||
inbox_message_id: str = Query(...),
|
||||
|
|
@ -686,12 +752,46 @@ async def fetch_job_departments(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/jobs/requisition-statuses/fetch")
|
||||
async def fetch_requisition_statuses(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Hiring-lifecycle tags for the Jobs status dropdown (open/on_hold/closed/completed)."""
|
||||
try:
|
||||
service=JobPost(session=session)
|
||||
data=await service.fetch_requisition_statuses()
|
||||
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/jobs/status-history/fetch")
|
||||
async def fetch_job_status_history(
|
||||
job_post_id:str=Query(...),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Who changed requisition_status on one job, from what, to what, and when."""
|
||||
try:
|
||||
service=JobPost(session=session)
|
||||
data=await service.fetch_status_history(job_post_id)
|
||||
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/jobs/fetch")
|
||||
async def fetch_jobs(
|
||||
search: str | None = Query(None),
|
||||
department: str | None = Query(None),
|
||||
requisition_status: 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;
|
||||
# a 200 ceiling used to 422 the SPA and render an empty requisition list.
|
||||
top: int | None = Query(10, ge=1, le=500),
|
||||
|
|
@ -707,7 +807,8 @@ async def fetch_jobs(
|
|||
service=JobPost(session=session)
|
||||
data,total=await service.fetch_jobs(
|
||||
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})
|
||||
except HTTPException:
|
||||
|
|
@ -722,6 +823,7 @@ async def export_jobs(
|
|||
department: str | None = Query(None),
|
||||
requisition_status: str | None = Query(None),
|
||||
employment_type: str | None = Query(None),
|
||||
hiring_manager_id: str | None = Query(None),
|
||||
active_only: bool = Query(False),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
|
@ -731,7 +833,8 @@ async def export_jobs(
|
|||
service=JobPost(session=session)
|
||||
data,_=await service.fetch_jobs(
|
||||
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"
|
||||
return Response(
|
||||
|
|
@ -825,6 +928,7 @@ async def fetch_interview(
|
|||
from_date:datetime=Query(None),
|
||||
to_date:datetime=Query(None),
|
||||
status:str=Query(None),
|
||||
recruiter_id:str=Query(None),
|
||||
top:int=Query(None),
|
||||
skip:int=Query(0,ge=0),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
|
|
@ -832,14 +936,14 @@ async def fetch_interview(
|
|||
):
|
||||
try:
|
||||
service=Interview(session=session)
|
||||
if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or top is not None):
|
||||
if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or recruiter_id or top is not None):
|
||||
data,total=await service.get_interviews_range(
|
||||
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip,
|
||||
from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip,
|
||||
)
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
data=await service.get_interview(
|
||||
interview_id=interview_id,inbox_id=inbox_id,
|
||||
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip,
|
||||
from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip,
|
||||
)
|
||||
if isinstance(data,tuple):
|
||||
data,total=data
|
||||
|
|
@ -1104,12 +1208,16 @@ async def fetch_pipeline_transitions(
|
|||
@router.get("/job/assignments/fetch")
|
||||
async def fetch_job_assignments(
|
||||
job_post_id:str=Query(...),
|
||||
current_only:bool=Query(True),
|
||||
assignment_role:str=Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
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})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -40,17 +40,48 @@ class JobAssignments(SQLModel, table=True):
|
|||
return result.scalars().first()
|
||||
|
||||
@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)
|
||||
if uid is None:
|
||||
return []
|
||||
statement = select(cls).where(cls.job_post_id == uid)
|
||||
if current_only:
|
||||
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())
|
||||
result = await session.execute(statement)
|
||||
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
|
||||
async def insert_assignment(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
|
|
@ -74,6 +105,7 @@ class JobAssignments(SQLModel, table=True):
|
|||
@classmethod
|
||||
async def count_open_reqs_by_users(cls, session: AsyncSession, user_ids):
|
||||
"""Open requisitions per user: current assignments joined to open job_posts."""
|
||||
from job.job_post.enums import RequisitionStatus
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
uids = [u for u in (user_ids or []) if u]
|
||||
|
|
@ -86,7 +118,7 @@ class JobAssignments(SQLModel, table=True):
|
|||
.where(
|
||||
cls.user_id.in_(uids),
|
||||
cls.valid_to.is_(None),
|
||||
JobPosts.requisition_status == "open",
|
||||
JobPosts.requisition_status == RequisitionStatus.OPEN.value,
|
||||
JobPosts.is_deleted == False, # noqa: E712
|
||||
)
|
||||
.group_by(cls.user_id)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
"id": str(row.id),
|
||||
"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,
|
||||
"valid_from": row.valid_from.isoformat() if row.valid_from 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,
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
"id": str(row.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,
|
||||
"valid_from": row.valid_from.isoformat() if row.valid_from 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,58 +3,129 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from job.assignment.models import ApplicationAssignments, JobAssignments
|
||||
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 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:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def _require_recruiter(self,user_id):
|
||||
role=await Roles.get_role_by_name(self.session,EnumRoles.RECRUITER.value)
|
||||
async def require_role(self,user_id,role_enum,field_name):
|
||||
role=await Roles.get_role_by_name(self.session,role_enum.value)
|
||||
user=await Users.get_user_by_id(self.session,user_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
|
||||
|
||||
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:
|
||||
raise HTTPException(status_code=400,detail="job_post_id is required")
|
||||
rows=await JobAssignments.fetch_by_job(self.session,job_post_id)
|
||||
return [serialize_job_assignment(r) for r in rows]
|
||||
role=self._job_role(assignment_role) if assignment_role else None
|
||||
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):
|
||||
user_id=payload.get("user_id")
|
||||
job_post_id=payload.get("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")
|
||||
await self._require_recruiter(user_id)
|
||||
fields={
|
||||
"job_post_id":JobAssignments._as_uuid(job_post_id),
|
||||
"user_id":JobAssignments._as_uuid(user_id),
|
||||
"assignment_role":payload.get("assignment_role") or "primary_recruiter",
|
||||
"assigned_by":JobAssignments._as_uuid(
|
||||
current_user.get("id") if isinstance(current_user,dict) else None
|
||||
),
|
||||
}
|
||||
if not fields["job_post_id"] or not fields["user_id"] or not fields["assigned_by"]:
|
||||
raise HTTPException(status_code=422,detail="Invalid job_post_id, user_id, or assigned_by")
|
||||
row=await JobAssignments.insert_assignment(self.session,fields)
|
||||
return serialize_job_assignment(row)
|
||||
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")
|
||||
role=self._job_role(payload.get("assignment_role"))
|
||||
await self.require_role(user_id,JOB_ASSIGNMENT_ROLES[role],"user_id")
|
||||
assigned_by=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})
|
||||
names=await Users.names_by_ids(
|
||||
self.session,[row.user_id,row.assigned_by] if row else [],
|
||||
)
|
||||
return serialize_job_assignment(row,names=names) if row else None
|
||||
|
||||
async def list_application_assignments(self,inbox_id):
|
||||
if inbox_id is None:
|
||||
raise HTTPException(status_code=400,detail="inbox_id is required")
|
||||
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):
|
||||
user_id=payload.get("user_id")
|
||||
inbox_id=payload.get("inbox_id")
|
||||
if not user_id or inbox_id is None:
|
||||
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={
|
||||
"inbox_id":int(inbox_id),
|
||||
"user_id":ApplicationAssignments._as_uuid(user_id),
|
||||
|
|
@ -66,4 +137,5 @@ class Assignment:
|
|||
if not fields["user_id"] or not fields["assigned_by"]:
|
||||
raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by")
|
||||
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 fastapi import HTTPException
|
||||
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, func, or_
|
||||
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
|
@ -175,6 +175,45 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
return counts
|
||||
except Exception as 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
|
||||
def _as_uuid(record_id) -> uuid.UUID | 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))
|
||||
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
|
||||
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."""
|
||||
|
|
@ -380,25 +431,27 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
return out
|
||||
|
||||
# ---- CV bank -----------------------------------------------------------
|
||||
# apply_via="cv_bank" rows are a private store of CVs with NO job, NO user
|
||||
# account and NO inbox entry — deliberately invisible to Candidates,
|
||||
# Pipeline (whose list inner-joins Users/JobPosts) and the Inbox. They wait
|
||||
# until a recruiter picks them up; email is captured only when the CV
|
||||
# contains one.
|
||||
# apply_via="cv_bank" marks origin: the CV Import "No job" tab. Unassigned
|
||||
# rows (job_post_id IS NULL) are the bank; Job Matching assigns a job_post_id
|
||||
# (and a user account) so get_all's Users/JobPosts inner joins pick them up
|
||||
# as normal applications. apply_via stays "cv_bank" so Matching can still
|
||||
# list them. No inbox entry.
|
||||
|
||||
@classmethod
|
||||
async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email,
|
||||
candidate_name, full_text, file_name,
|
||||
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.
|
||||
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
|
||||
(same pattern as create_manual_upload_candidate) so the person shows
|
||||
up on the Candidates screen; unlike an application there is still no
|
||||
inbox entry, no scoring, and no setup email. A CV with no detectable
|
||||
email banks fine and simply stays account-less."""
|
||||
linkedin_url is the employment-agent extraction (None when the CV has
|
||||
none — never a constructed slug). When the CV carries an email, the
|
||||
candidate ACCOUNT is created/reused so the person shows up on the
|
||||
Candidates screen; unlike an application there is still no inbox entry,
|
||||
no scoring, and no setup email. A CV with no detectable email banks
|
||||
fine and simply stays account-less."""
|
||||
import os
|
||||
|
||||
from role.models import EnumRoles, Roles
|
||||
|
|
@ -427,12 +480,21 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
user.is_active = True
|
||||
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(
|
||||
candidate_email=email,
|
||||
candidate_name=(candidate_name or "").strip() or (email or ""),
|
||||
job_post_id=None,
|
||||
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",
|
||||
user_id=user.id if user else None,
|
||||
created_by=cls._as_uuid(created_by),
|
||||
|
|
@ -455,14 +517,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
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 = (
|
||||
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
|
||||
result = await session.execute(
|
||||
select(cls)
|
||||
.where(cls.apply_via == "cv_bank")
|
||||
.where(*bank)
|
||||
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
|
@ -470,12 +534,104 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
return list(result.scalars().all()), total
|
||||
|
||||
@classmethod
|
||||
async def delete_bank_cv(cls, session: AsyncSession, record_id):
|
||||
"""Hard delete, bank rows only — never reachable for application rows.
|
||||
The cv_bank_files row goes with it via ON DELETE CASCADE."""
|
||||
async def list_matching(cls, session: AsyncSession, *, assigned=None,
|
||||
search=None, limit=100, offset=0):
|
||||
"""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)
|
||||
if not row or row.apply_via != "cv_bank":
|
||||
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)
|
||||
if file_row:
|
||||
await session.delete(file_row)
|
||||
|
|
@ -685,6 +841,11 @@ class Interviews(SQLModel, table=True):
|
|||
interview_type: str = Field(default="")
|
||||
interview_status: str = Field(default="")
|
||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
||||
# Optional denorm so Recruiter Hub can join interviews → job_posts.current_recruiter_id
|
||||
# without walking inbox. Filled on create from the application's assigned job;
|
||||
# migration 011 added the columns. user_id is the candidate, not the recruiter.
|
||||
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
||||
graph_event_id: str | None = Field(default=None)
|
||||
web_link: str | None = Field(default=None)
|
||||
inbox: Optional["Inbox"] = Relationship(
|
||||
|
|
@ -725,6 +886,28 @@ class Interviews(SQLModel, table=True):
|
|||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
def scoped_to_recruiter(cls, statement, recruiter_id):
|
||||
"""Restrict an Interviews select to the recruiter who owns the job.
|
||||
|
||||
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id)
|
||||
→ job_posts.current_recruiter_id. Interviews with no job drop out.
|
||||
"""
|
||||
from inbox.models import Inbox, Inbox_Messages
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is None:
|
||||
return statement
|
||||
job_id = func.coalesce(cls.job_post_id, Inbox_Messages.assigned_job_post_id)
|
||||
return (
|
||||
statement
|
||||
.outerjoin(Inbox, cls.inbox_id == Inbox.id)
|
||||
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
|
||||
.outerjoin(JobPosts, JobPosts.id == job_id)
|
||||
.where(JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_interviews_in_range(
|
||||
cls,
|
||||
|
|
@ -733,6 +916,7 @@ class Interviews(SQLModel, table=True):
|
|||
from_date=None,
|
||||
to_date=None,
|
||||
status: str | None = None,
|
||||
recruiter_id=None,
|
||||
top: int | None = None,
|
||||
skip: int = 0,
|
||||
):
|
||||
|
|
@ -743,6 +927,8 @@ class Interviews(SQLModel, table=True):
|
|||
statement = statement.where(cls.interview_date < to_date)
|
||||
if status:
|
||||
statement = statement.where(cls.interview_status == status)
|
||||
if recruiter_id:
|
||||
statement = cls.scoped_to_recruiter(statement, recruiter_id)
|
||||
count_statement = select(func.count()).select_from(statement.subquery())
|
||||
total = (await session.execute(count_statement)).scalar_one()
|
||||
statement = (
|
||||
|
|
@ -755,6 +941,41 @@ class Interviews(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
@classmethod
|
||||
async def count_between(cls, session: AsyncSession, start, end, recruiter_id=None):
|
||||
statement = select(func.count()).select_from(cls).where(
|
||||
cls.interview_date >= start,
|
||||
cls.interview_date < end,
|
||||
)
|
||||
if recruiter_id:
|
||||
statement = cls.scoped_to_recruiter(statement, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
@classmethod
|
||||
async def count_upcoming(cls, session: AsyncSession, as_of, recruiter_id=None):
|
||||
statement = select(func.count()).select_from(cls).where(
|
||||
cls.interview_status.ilike("scheduled"),
|
||||
cls.interview_date >= as_of,
|
||||
)
|
||||
if recruiter_id:
|
||||
statement = cls.scoped_to_recruiter(statement, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
@classmethod
|
||||
async def next_scheduled_at(cls, session: AsyncSession, as_of, recruiter_id=None):
|
||||
statement = select(
|
||||
func.min(func.coalesce(cls.interview_time, cls.interview_date))
|
||||
).select_from(cls).where(
|
||||
cls.interview_status.ilike("scheduled"),
|
||||
cls.interview_date >= as_of,
|
||||
)
|
||||
if recruiter_id:
|
||||
statement = cls.scoped_to_recruiter(statement, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
||||
@classmethod
|
||||
async def job_titles_by_inbox(cls, session: AsyncSession, inbox_ids) -> dict[int, str]:
|
||||
"""Resolve {inbox_id: job_title} for a page of interview rows.
|
||||
|
|
@ -1117,6 +1338,113 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
||||
@classmethod
|
||||
async def avg_time_to_hire(
|
||||
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||
):
|
||||
"""Mean days from first stage (from_stage IS NULL) to HIRED, optional filters."""
|
||||
from inbox.enums import Candidate_application_Status
|
||||
from inbox.models import Inbox, Inbox_Messages
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
entry = cls.__table__.alias("entry")
|
||||
hire = cls.__table__.alias("hire")
|
||||
days = func.extract("epoch", hire.c.valid_from - entry.c.valid_from) / 86400.0
|
||||
statement = (
|
||||
select(func.avg(days))
|
||||
.select_from(
|
||||
hire.join(
|
||||
entry,
|
||||
and_(
|
||||
hire.c.inbox_id == entry.c.inbox_id,
|
||||
entry.c.from_stage.is_(None),
|
||||
),
|
||||
)
|
||||
)
|
||||
.where(hire.c.to_stage == Candidate_application_Status.HIRED.value)
|
||||
)
|
||||
if from_date is not None:
|
||||
statement = statement.where(hire.c.valid_from >= from_date)
|
||||
if to_date is not None:
|
||||
statement = statement.where(hire.c.valid_from < to_date)
|
||||
if department or recruiter_id:
|
||||
statement = (
|
||||
statement
|
||||
.outerjoin(Inbox, hire.c.inbox_id == Inbox.id)
|
||||
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
|
||||
.outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id)
|
||||
)
|
||||
if department:
|
||||
statement = statement.where(JobPosts.department == department)
|
||||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
value = result.scalar_one()
|
||||
return float(value) if value is not None else None
|
||||
|
||||
@classmethod
|
||||
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
|
||||
if not department and not recruiter_id:
|
||||
return statement
|
||||
from inbox.models import Inbox, Inbox_Messages
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
statement = (
|
||||
statement
|
||||
.outerjoin(Inbox, cls.inbox_id == Inbox.id)
|
||||
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
|
||||
.outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id)
|
||||
)
|
||||
if department:
|
||||
statement = statement.where(JobPosts.department == department)
|
||||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
async def count_hires(
|
||||
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||
):
|
||||
from inbox.enums import Candidate_application_Status
|
||||
|
||||
statement = select(func.count()).select_from(cls).where(
|
||||
cls.to_stage == Candidate_application_Status.HIRED.value
|
||||
)
|
||||
if from_date is not None:
|
||||
statement = statement.where(cls.valid_from >= from_date)
|
||||
if to_date is not None:
|
||||
statement = statement.where(cls.valid_from < to_date)
|
||||
statement = cls.scoped_to_job(statement, department, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
@classmethod
|
||||
async def counts_hires_by_month(
|
||||
cls, session: AsyncSession, start, department=None, recruiter_id=None,
|
||||
):
|
||||
from inbox.enums import Candidate_application_Status
|
||||
|
||||
month_bucket = func.date_trunc("month", cls.valid_from)
|
||||
statement = (
|
||||
select(month_bucket.label("month"), func.count().label("count"))
|
||||
.select_from(cls)
|
||||
.where(
|
||||
cls.to_stage == Candidate_application_Status.HIRED.value,
|
||||
cls.valid_from >= start,
|
||||
)
|
||||
.group_by(month_bucket)
|
||||
.order_by(month_bucket)
|
||||
)
|
||||
statement = cls.scoped_to_job(statement, department, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return [(month, int(count or 0)) for month, count in result.all()]
|
||||
|
||||
|
||||
class CandidateHistory(SQLModel, table=True):
|
||||
"""Append-only audit log for one candidate (users.id), scoped to an application.
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
return {
|
||||
"id":str(row.id) if row.id else None,
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ from pathlib import Path
|
|||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException
|
||||
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.models.scoring import CompletedCandidate
|
||||
from app.services.pdf import extract_resume,sanitize_filename
|
||||
|
|
@ -26,7 +23,7 @@ from job.candidate.plugins import (
|
|||
get_scoring_settings,
|
||||
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.serializers import serialize_job_post
|
||||
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:
|
||||
"""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()
|
||||
if not text:
|
||||
return None
|
||||
|
|
@ -988,22 +985,13 @@ class CandidateView:
|
|||
notes=[]
|
||||
uid=Notes._as_uuid(user_id) if user_id else None
|
||||
if uid is not None:
|
||||
result=await self.session.execute(
|
||||
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()]
|
||||
notes=[serialize_note(r) for r in await Notes.get_notes_by_user(self.session,uid)]
|
||||
|
||||
# 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
|
||||
# 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_rows=[]
|
||||
if ats_ids:
|
||||
result=await self.session.execute(select(AtsResults).where(AtsResults.id.in_(ats_ids)))
|
||||
ats_rows=list(result.scalars().all())
|
||||
ats_rows=await AtsResults.get_by_ids(self.session,ats_ids) if ats_ids else []
|
||||
assigned_uid=AtsResults._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
|
||||
chosen=None
|
||||
if assigned_uid is not None:
|
||||
|
|
@ -1088,3 +1076,52 @@ class CandidateView:
|
|||
raise HTTPException(status_code=404,detail="Not found")
|
||||
name=(entry.get("name") or path.name).strip() or 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)
|
||||
|
||||
@classmethod
|
||||
async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None):
|
||||
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
|
||||
if not department and not recruiter_id:
|
||||
return statement
|
||||
from job.job_post.models import JobPosts
|
||||
statement = statement.outerjoin(JobPosts, cls.job_post_id == JobPosts.id)
|
||||
if department:
|
||||
statement = statement.where(JobPosts.department == department)
|
||||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement = statement.where(JobPosts.current_recruiter_id == rid)
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None,
|
||||
department=None, recruiter_id=None):
|
||||
statement = select(func.coalesce(func.sum(cls.amount), 0.0))
|
||||
if from_date is not None:
|
||||
statement = statement.where(cls.incurred_at >= from_date)
|
||||
if to_date is not None:
|
||||
statement = statement.where(cls.incurred_at < to_date)
|
||||
statement = cls.scoped_to_job(statement, department, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return float(result.scalar_one() or 0.0)
|
||||
|
||||
@classmethod
|
||||
async def sum_by_source_channel(
|
||||
cls, session: AsyncSession, *, from_date=None, to_date=None,
|
||||
department=None, recruiter_id=None,
|
||||
):
|
||||
statement = select(
|
||||
cls.source_channel_id,
|
||||
func.coalesce(func.sum(cls.amount), 0.0),
|
||||
).where(cls.source_channel_id.is_not(None))
|
||||
if from_date is not None:
|
||||
statement = statement.where(cls.incurred_at >= from_date)
|
||||
if to_date is not None:
|
||||
statement = statement.where(cls.incurred_at < to_date)
|
||||
statement = cls.scoped_to_job(statement, department, recruiter_id)
|
||||
statement = statement.group_by(cls.source_channel_id)
|
||||
result = await session.execute(statement)
|
||||
return {channel_id: float(total or 0.0) for channel_id, total in result.all()}
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
|
||||
from job.candidate.models import Feedback
|
||||
from job.feedback.models import FeedbackTemplates
|
||||
|
|
@ -15,13 +13,7 @@ class FeedbackView:
|
|||
self.session=session
|
||||
|
||||
async def _load(self,record_id):
|
||||
uid=Feedback._as_uuid(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()
|
||||
return await Feedback.get_feedback_by_id(self.session,record_id)
|
||||
|
||||
async def get_feedback(self,feedback_id=None,inbox_id=None):
|
||||
if feedback_id:
|
||||
|
|
@ -31,13 +23,8 @@ class FeedbackView:
|
|||
return serialize_feedback(row)
|
||||
if inbox_id is None:
|
||||
raise HTTPException(status_code=400,detail="feedback_id or inbox_id is required")
|
||||
result=await self.session.execute(
|
||||
select(Feedback)
|
||||
.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()]
|
||||
rows=await Feedback.get_feedback_by_inbox(self.session,int(inbox_id))
|
||||
return [serialize_feedback(r) for r in rows]
|
||||
|
||||
async def create_feedback(self,payload,current_user):
|
||||
fields={
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import logging
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from inbox.models import Inbox
|
||||
from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE
|
||||
|
|
@ -117,10 +116,5 @@ class HistoryRecorder:
|
|||
self.session, user_id, limit=limit, offset=offset
|
||||
)
|
||||
actor_ids = {r.actor_id for r in rows if r.actor_id}
|
||||
names = {}
|
||||
if actor_ids:
|
||||
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
|
||||
names = await Users.names_by_ids(self.session, actor_ids)
|
||||
return [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows], total
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class Interview:
|
|||
async def _serialize(self,row):
|
||||
return serialize_interview(row,job_title=await self._job_title_for(row))
|
||||
|
||||
async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,top=None,skip=0):
|
||||
async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,recruiter_id=None,top=None,skip=0):
|
||||
if interview_id:
|
||||
row=await Interviews.get_interview_by_id(self.session,interview_id)
|
||||
if not row:
|
||||
|
|
@ -33,18 +33,19 @@ class Interview:
|
|||
if inbox_id is not None:
|
||||
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
|
||||
return [serialize_interview(r) for r in rows]
|
||||
if from_date is not None or to_date is not None or status is not None or top is not None:
|
||||
if from_date is not None or to_date is not None or status is not None or recruiter_id or top is not None:
|
||||
return await self.get_interviews_range(
|
||||
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip,
|
||||
from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip,
|
||||
)
|
||||
raise HTTPException(status_code=400,detail="interview_id or inbox_id is required")
|
||||
|
||||
async def get_interviews_range(self,from_date=None,to_date=None,status=None,top=None,skip=0):
|
||||
async def get_interviews_range(self,from_date=None,to_date=None,status=None,recruiter_id=None,top=None,skip=0):
|
||||
rows,total=await Interviews.get_interviews_in_range(
|
||||
self.session,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
status=status,
|
||||
recruiter_id=recruiter_id,
|
||||
top=top,
|
||||
skip=skip,
|
||||
)
|
||||
|
|
@ -52,6 +53,7 @@ class Interview:
|
|||
return [serialize_interview(r,job_title=titles.get(r.inbox_id)) for r in rows],total
|
||||
|
||||
async def create_interview(self,payload,current_user=None):
|
||||
from inbox.models import Inbox
|
||||
fields={
|
||||
"interview_date":payload.get("interview_date"),
|
||||
"interview_time":payload.get("interview_time"),
|
||||
|
|
@ -59,6 +61,13 @@ class Interview:
|
|||
"interview_status":payload.get("interview_status") or "",
|
||||
"inbox_id":payload.get("inbox_id"),
|
||||
}
|
||||
inbox=await Inbox.get_inbox_with_message(self.session,payload.get("inbox_id"))
|
||||
if inbox:
|
||||
if inbox.user_id:
|
||||
fields["user_id"]=inbox.user_id
|
||||
msg=inbox.messages
|
||||
if msg and msg.assigned_job_post_id:
|
||||
fields["job_post_id"]=msg.assigned_job_post_id
|
||||
fields={k:v for k,v in fields.items() if v is not None}
|
||||
row=await Interviews.insert_interview(self.session,fields)
|
||||
when=row.interview_date or row.interview_time
|
||||
|
|
|
|||
|
|
@ -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
|
||||
BORDER_TINT = "CBDCD2"
|
||||
|
||||
STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold"}
|
||||
STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700"}
|
||||
STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold", "completed": "Completed"}
|
||||
STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700", "completed": "0F6E56"}
|
||||
|
||||
# (header, column width)
|
||||
COLUMNS = [
|
||||
|
|
@ -36,6 +36,7 @@ COLUMNS = [
|
|||
("Status", 10),
|
||||
("Publishing", 12),
|
||||
("Recruiter", 18),
|
||||
("Hiring Manager", 18),
|
||||
("Created By", 18),
|
||||
("Created", 13),
|
||||
("Requirements", 46),
|
||||
|
|
@ -126,6 +127,7 @@ def build_jobs_workbook(rows) -> bytes:
|
|||
STATUS_LABELS.get(status_key, status_key),
|
||||
row.get("status") or "",
|
||||
row.get("recruiter_name") or "",
|
||||
row.get("hiring_manager_name") or "",
|
||||
row.get("created_by_name") or "",
|
||||
_created(row),
|
||||
_bullets(row.get("requirements")),
|
||||
|
|
@ -145,7 +147,7 @@ def build_jobs_workbook(rows) -> bytes:
|
|||
status_cell.alignment = center
|
||||
if status_key in STATUS_COLORS:
|
||||
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:
|
||||
created_cell.number_format = "dd mmm yyyy"
|
||||
for c in (14, 15, 16):
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, JSON, func, or_
|
||||
from sqlalchemy import DateTime, JSON, Index, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
from job.job_post.enums import RequisitionStatus
|
||||
|
||||
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
||||
from users.models import Users
|
||||
|
||||
|
|
@ -20,12 +22,12 @@ class JobPosts(SQLModel, table=True):
|
|||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
title: str = Field(index=True)
|
||||
|
||||
# foreign_keys is required, not decoration: current_recruiter_id below is a
|
||||
# SECOND foreign key into users.id, so the join condition is ambiguous without
|
||||
# it and every mapper fails to initialize. `user` is the AUTHOR of the post —
|
||||
# current_recruiter_id is deliberately a bare column with no relationship of
|
||||
# its own, because Users already carries five selectin relations that load on
|
||||
# every authenticated request. Same pairing as Notes.user / Notes.author.
|
||||
# foreign_keys is required, not decoration: current_recruiter_id and
|
||||
# hiring_manager_id below are extra FKs into users.id, so the join is
|
||||
# ambiguous without it and every mapper fails to initialize. `user` is the
|
||||
# AUTHOR of the post. The recruiter and hiring-manager columns stay bare —
|
||||
# Users already carries five selectin relations that load on every
|
||||
# authenticated request. Same pairing as Notes.user / Notes.author.
|
||||
user: Optional["Users"] = Relationship(
|
||||
back_populates="job_posts",
|
||||
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))
|
||||
status: str = Field(default="draft")
|
||||
buffer_error: str | None = Field(default=None)
|
||||
# requisition_status is the hiring lifecycle (open/closed/on_hold). Distinct from
|
||||
# requisition_status is the hiring lifecycle (RequisitionStatus). Distinct from
|
||||
# `status`, which tracks Buffer publishing (draft/scheduled/published/failed).
|
||||
# server_default is load-bearing: this column arrives as an ALTER on a populated table.
|
||||
requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"})
|
||||
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
||||
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")
|
||||
# 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_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,
|
||||
requisition_status: str | None = None,
|
||||
employment_type: str | None = None,
|
||||
hiring_manager_id: uuid.UUID | None = None,
|
||||
):
|
||||
if ids:
|
||||
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)
|
||||
if 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())
|
||||
total = (await session.execute(count_statement)).scalar_one()
|
||||
statement = statement.order_by(cls.created_at.desc())
|
||||
|
|
@ -185,10 +195,118 @@ class JobPosts(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
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
|
||||
async def insert_job_post(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
session.add(JobPostStatusHistory(
|
||||
job_post_id=row.id,
|
||||
from_status=None,
|
||||
to_status=row.requisition_status or "open",
|
||||
changed_by=row.created_by,
|
||||
))
|
||||
await session.commit()
|
||||
return await cls.get_job_post_by_id(session, row.id)
|
||||
|
||||
|
|
@ -263,23 +381,68 @@ class JobPosts(SQLModel, table=True):
|
|||
return row
|
||||
|
||||
@classmethod
|
||||
async def set_requisition_status(cls, session: AsyncSession, record_id: str, status: str):
|
||||
async def set_requisition_status(
|
||||
cls, session: AsyncSession, record_id: str, status: str, *, changed_by=None,
|
||||
):
|
||||
row = await cls.get_job_post_by_id(session, record_id)
|
||||
if not row or row.is_deleted:
|
||||
return None
|
||||
previous = row.requisition_status
|
||||
if previous == status:
|
||||
return row
|
||||
row.requisition_status = status
|
||||
if status == "closed":
|
||||
if previous != "closed" or row.closed_at is None:
|
||||
terminal = status in ("closed", "completed")
|
||||
if terminal:
|
||||
if previous not in ("closed", "completed") or row.closed_at is None:
|
||||
row.closed_at = _now()
|
||||
else:
|
||||
row.closed_at = None
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
actor = cls._as_uuid(changed_by) if changed_by is not None else None
|
||||
session.add(JobPostStatusHistory(
|
||||
job_post_id=row.id,
|
||||
from_status=previous,
|
||||
to_status=status,
|
||||
changed_by=actor,
|
||||
))
|
||||
await session.commit()
|
||||
return await cls.get_job_post_by_id(session, record_id)
|
||||
|
||||
|
||||
class JobPostStatusHistory(SQLModel, table=True):
|
||||
"""Who changed job_posts.requisition_status, from what, to what, and when.
|
||||
|
||||
Distinct from job_assignments (ownership intervals). The Jobs History tab
|
||||
merges both. Applied on prod by migrations/manual/016_job_post_status_history.sql.
|
||||
"""
|
||||
|
||||
__tablename__ = "job_post_status_history"
|
||||
__table_args__ = (
|
||||
Index("ix_job_post_status_history_job_created", "job_post_id", "created_at"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
job_post_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True)
|
||||
from_status: str | None = Field(default=None)
|
||||
to_status: str
|
||||
changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
actor_kind: str = Field(default="user")
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
|
||||
@classmethod
|
||||
async def fetch_by_job(cls, session: AsyncSession, job_post_id):
|
||||
uid = JobPosts._as_uuid(job_post_id)
|
||||
if uid is None:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(cls)
|
||||
.where(cls.job_post_id == uid)
|
||||
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class JobPostImages(SQLModel, table=True):
|
||||
"""Cover image of a job post, stored as bytes IN the database.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"title": row.title,
|
||||
# Talent Pool / candidate filters key off attached job_posts.department.
|
||||
"department": row.department or None,
|
||||
"employment_type": row.employment_type,
|
||||
"location": row.location,
|
||||
"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.
|
||||
|
||||
Deliberately separate from serialize_job_post: that payload is shared by the
|
||||
inbox, candidate and matching paths, and widening it would change five
|
||||
response shapes at once.
|
||||
inbox, candidate and matching paths. department is the one shared field —
|
||||
talent-pool filters key off it on attached job_posts.
|
||||
"""
|
||||
return {
|
||||
"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,
|
||||
"current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None,
|
||||
"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,
|
||||
"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_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_status_history(row, *, changed_by_name=None) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||
"from_status": row.from_status,
|
||||
"from_label": _status_label(row.from_status),
|
||||
"to_status": row.to_status,
|
||||
"to_label": _status_label(row.to_status),
|
||||
"changed_by": str(row.changed_by) if row.changed_by else None,
|
||||
"changed_by_name": changed_by_name,
|
||||
"actor_kind": row.actor_kind,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import logging
|
|||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
|
@ -10,7 +11,10 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, model_validator
|
||||
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 job.job_post.plugins import (
|
||||
BufferError,
|
||||
|
|
@ -22,7 +26,7 @@ from job.job_post.plugins import (
|
|||
render_job_post,
|
||||
resolve_channel,
|
||||
)
|
||||
from job.job_post.serializers import serialize_job_post, serialize_job_row
|
||||
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_status_history
|
||||
|
||||
load_dotenv()
|
||||
logger=logging.getLogger("job.job_post")
|
||||
|
|
@ -60,6 +64,8 @@ class JobPostCreate(BaseModel):
|
|||
scheduler_time: time | None = time(0, 0, 0)
|
||||
scheduler_date: date | None = None
|
||||
due_at: str | None = None
|
||||
hiring_manager_id: UUID
|
||||
current_recruiter_id: UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_and_due_at(self):
|
||||
|
|
@ -138,7 +144,24 @@ class JobPost:
|
|||
# Column default is "linkedin"; an unpublished requisition must not
|
||||
# masquerade as a LinkedIn post.
|
||||
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)
|
||||
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:
|
||||
return serialize_job_post(row)
|
||||
|
|
@ -186,21 +209,42 @@ class JobPost:
|
|||
async def fetch_departments(self,active_only=False):
|
||||
return await JobPosts.list_departments(self.session,active_only=active_only)
|
||||
|
||||
async def fetch_requisition_statuses(self):
|
||||
return RequisitionStatus.as_list()
|
||||
|
||||
async def fetch_status_history(self,job_post_id):
|
||||
job=await JobPosts.get_job_post_by_id(self.session,job_post_id)
|
||||
if not job or job.is_deleted:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id)
|
||||
names=await Users.names_by_ids(self.session,[r.changed_by for r in rows])
|
||||
return [
|
||||
serialize_status_history(r,changed_by_name=names.get(str(r.changed_by)))
|
||||
for r in rows
|
||||
]
|
||||
|
||||
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
|
||||
employment_type=None,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(
|
||||
self.session,search=search,top=top,skip=skip,active_only=active_only,
|
||||
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(
|
||||
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])
|
||||
return [
|
||||
serialize_job_row(
|
||||
r,
|
||||
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),
|
||||
)
|
||||
for r in rows
|
||||
|
|
@ -208,13 +252,21 @@ class JobPost:
|
|||
|
||||
async def _job_row(self,row):
|
||||
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):
|
||||
if not current_user:
|
||||
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",
|
||||
"salary","experience_min","experience_max","description")
|
||||
fields={k:payload[k] for k in allowed if k in payload}
|
||||
|
|
@ -229,11 +281,41 @@ class JobPost:
|
|||
fields["salary"]=str(high)
|
||||
if "department" in fields and fields["department"] is None:
|
||||
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:
|
||||
raise HTTPException(status_code=400,detail="No fields to update")
|
||||
row=await JobPosts.update_job_post(self.session,job_post_id,fields)
|
||||
if not row:
|
||||
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)
|
||||
|
||||
async def delete_job(self,job_post_id,current_user):
|
||||
|
|
@ -285,13 +367,20 @@ class JobPost:
|
|||
if not current_user:
|
||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||
status=(payload.get("requisition_status") or "").strip()
|
||||
allowed=("open","closed","on_hold")
|
||||
if status not in allowed:
|
||||
raise HTTPException(status_code=422,detail=f"requisition_status must be one of {', '.join(allowed)}")
|
||||
row=await JobPosts.set_requisition_status(self.session,job_post_id,status)
|
||||
parsed=RequisitionStatus.parse(status)
|
||||
if parsed is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"requisition_status must be one of {', '.join(RequisitionStatus.values())}",
|
||||
)
|
||||
status=parsed.value
|
||||
actor=current_user.get("id") if isinstance(current_user,dict) else None
|
||||
row=await JobPosts.set_requisition_status(
|
||||
self.session,job_post_id,status,changed_by=actor,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
if status=="closed":
|
||||
if status==RequisitionStatus.CLOSED.value:
|
||||
try:
|
||||
from notifications.models import Notifications
|
||||
raw=row.current_recruiter_id or (current_user.get("id") if current_user else None)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
|
||||
from job.candidate.models import Notes
|
||||
from job.history.enums import HistoryEvent
|
||||
|
|
@ -14,13 +12,7 @@ class Note:
|
|||
self.session=session
|
||||
|
||||
async def _load(self,record_id):
|
||||
uid=Notes._as_uuid(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()
|
||||
return await Notes.get_note_by_id(self.session,record_id)
|
||||
|
||||
async def get_note(self,note_id=None,user_id=None):
|
||||
if note_id:
|
||||
|
|
@ -33,13 +25,8 @@ class Note:
|
|||
uid=Notes._as_uuid(user_id)
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=400,detail="Invalid user_id")
|
||||
result=await self.session.execute(
|
||||
select(Notes)
|
||||
.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()]
|
||||
rows=await Notes.get_notes_by_user(self.session,uid)
|
||||
return [serialize_note(r) for r in rows]
|
||||
|
||||
async def create_note(self,payload,current_user):
|
||||
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)
|
||||
return result.scalar_one()
|
||||
|
||||
@classmethod
|
||||
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
|
||||
if not department and not recruiter_id:
|
||||
return statement
|
||||
from job.job_post.models import JobPosts
|
||||
statement = statement.outerjoin(JobPosts, cls.job_post_id == JobPosts.id)
|
||||
if department:
|
||||
statement = statement.where(JobPosts.department == department)
|
||||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement = statement.where(JobPosts.current_recruiter_id == rid)
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
async def count_in_window(
|
||||
cls, session: AsyncSession, statuses=None, from_date=None, to_date=None,
|
||||
department=None, recruiter_id=None, *, exclude_draft=False,
|
||||
):
|
||||
statement = select(func.count()).select_from(cls)
|
||||
if exclude_draft:
|
||||
statement = statement.where(cls.status != "draft")
|
||||
elif statuses:
|
||||
statement = statement.where(cls.status.in_(statuses))
|
||||
stamp = func.coalesce(cls.sent_at, cls.responded_at, cls.created_at)
|
||||
if from_date is not None:
|
||||
statement = statement.where(stamp >= from_date)
|
||||
if to_date is not None:
|
||||
statement = statement.where(stamp < to_date)
|
||||
statement = cls.scoped_to_job(statement, department, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
class OfferStatusHistory(SQLModel, table=True):
|
||||
__tablename__ = "offer_status_history"
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ async def _build_recruiter_performance(session, f):
|
|||
row["avg_time_to_hire"] = _round(row.get("avg_time_to_hire"))
|
||||
columns = [
|
||||
{"key": "name", "label": "Recruiter"},
|
||||
{"key": "completed", "label": "Completed Requisitions"},
|
||||
{"key": "hires", "label": "Hires"},
|
||||
{"key": "open_reqs", "label": "Open Requisitions"},
|
||||
{"key": "avg_time_to_hire", "label": "Avg Time to Hire (days)"},
|
||||
|
|
|
|||
|
|
@ -258,6 +258,16 @@ class Roles(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
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
|
||||
async def count_roles(cls, session: AsyncSession, search: str | None):
|
||||
statement = (
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ import uuid
|
|||
from datetime import timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from role.models import Roles
|
||||
from tasks.models import Tasks
|
||||
|
|
@ -55,10 +53,7 @@ class Task:
|
|||
ids = [i for i in set(ids) if i]
|
||||
if not ids:
|
||||
return {}
|
||||
result = await self.session.execute(
|
||||
select(Users).options(selectinload(Users.role)).where(Users.id.in_(ids))
|
||||
)
|
||||
return {u.id: u for u in result.scalars().all()}
|
||||
return {u.id: u for u in await Users.get_by_ids(self.session, ids)}
|
||||
|
||||
async def _validate_assignee(self, assignee_id):
|
||||
"""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
|
||||
ids are looked up from the roles table, and the permission-tag guard on
|
||||
the route (tasks.create) still applies on top of this."""
|
||||
result = await self.session.execute(
|
||||
select(Roles).where(Roles.role_name.in_(CREATOR_ROLES))
|
||||
)
|
||||
allowed_ids = {r.id for r in result.scalars().all()}
|
||||
rows = await Roles.get_by_names(self.session, CREATOR_ROLES)
|
||||
allowed_ids = {r.id for r in rows}
|
||||
if current_user.get("role_id") not in allowed_ids:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
|
|
|
|||
|
|
@ -246,7 +246,12 @@ async def delete_user(
|
|||
@router.get("/managers/fetch")
|
||||
async def fetch_managers(
|
||||
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),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# 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.
|
||||
# foreign_keys must match the other side: job_posts.current_recruiter_id is a
|
||||
# second FK into this table, so this relation has to say it means created_by.
|
||||
# foreign_keys must match the other side: job_posts also has current_recruiter_id
|
||||
# and hiring_manager_id into this table, so this relation has to say created_by.
|
||||
job_posts: List[JobPosts] = Relationship(
|
||||
back_populates="user",
|
||||
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
|
||||
|
|
@ -117,6 +117,23 @@ class Users(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
async def list_by_role_name(cls, session: AsyncSession, role_name, user_id=None):
|
||||
"""Active (non-deleted) users whose Roles.role_name matches. Optional id filter."""
|
||||
statement = (
|
||||
select(cls)
|
||||
.join(Roles, cls.role_id == Roles.id)
|
||||
.where(
|
||||
Roles.role_name == role_name,
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
uid = cls._as_uuid(user_id) if user_id is not None else None
|
||||
if uid is not None:
|
||||
statement = statement.where(cls.id == uid)
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
||||
"""Resolve {user_id: name} in a single query.
|
||||
|
|
@ -133,6 +150,21 @@ class Users(SQLModel, table=True):
|
|||
)
|
||||
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
|
||||
async def get_user_by_id(cls, session: AsyncSession, record_id: str):
|
||||
uid = cls._as_uuid(record_id)
|
||||
|
|
|
|||
|
|
@ -114,13 +114,13 @@ class User:
|
|||
|
||||
async def get_managers(self):
|
||||
"""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)
|
||||
if role is None:
|
||||
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)
|
||||
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=[
|
||||
{
|
||||
"id": str(u.id),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
import { toStageCounts } from './pipeline'
|
||||
|
||||
/**
|
||||
* 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 } = {}) {
|
||||
return request('/analytics/hiring-trend/fetch', {
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -4,23 +4,28 @@ import { request } from '../lib/apiClient'
|
|||
assignments.js — who owns a requisition, and who owns an application.
|
||||
|
||||
Two parallel tables behind four routes (backend/job/app.py):
|
||||
job_assignments — a recruiter on a JOB POST (jobs.view / jobs.edit)
|
||||
application_assignments — a recruiter on ONE APPLICATION (candidates.view / candidates.edit)
|
||||
job_assignments — recruiter OR hiring manager on a JOB POST
|
||||
application_assignments — a recruiter on ONE APPLICATION
|
||||
|
||||
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
|
||||
or reassign route — `insert_assignment` closes the previous open interval and
|
||||
opens a new one, so assigning someone else IS the reassignment.
|
||||
now. Fetch defaults to current-only; pass currentOnly: false for the history
|
||||
log. Reassignment closes the previous open interval of the SAME role.
|
||||
|
||||
The server rejects any user whose role is not `recruiter` with a 422
|
||||
(Assignment._require_recruiter), which is why every picker here is sourced
|
||||
from /tasks/assignees/fetch — the one endpoint that already returns exactly
|
||||
the active recruiter-role users, and needs no rbac_users.view to call.
|
||||
Recruiter pickers use /tasks/assignees/fetch; hiring-manager pickers use
|
||||
/managers/fetch. Neither needs rbac_users.view. The current pointers also
|
||||
live on job_posts (current_recruiter_id, hiring_manager_id) and PATCH
|
||||
/jobs/update is the Jobs-screen write path.
|
||||
============================================================ */
|
||||
|
||||
/** Current recruiter(s) on one requisition. */
|
||||
export function listJob(jobPostId) {
|
||||
return request('/job/assignments/fetch', { params: { job_post_id: jobPostId } })
|
||||
/** Current or historical owners of one requisition. */
|
||||
export function listJob(jobPostId, { currentOnly, assignmentRole } = {}) {
|
||||
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. */
|
||||
|
|
@ -60,7 +65,8 @@ export function toAssignmentView(row, namesById) {
|
|||
return {
|
||||
id: row.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',
|
||||
jobPostId: row.job_post_id ?? null,
|
||||
inboxId: row.inbox_id ?? null,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
============================================================ */
|
||||
|
||||
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.
|
||||
*
|
||||
|
|
@ -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.
|
||||
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
|
||||
|
|
@ -290,7 +319,7 @@ export function createManual({
|
|||
put('current_position', currentPosition)
|
||||
put('platform', source)
|
||||
put('experience', experience)
|
||||
put('status', stage)
|
||||
put('status', STATUS_FROM_STAGE[stage] || stage)
|
||||
put('referral_by', referralBy)
|
||||
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_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', {
|
||||
// `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
|
||||
|
|
@ -30,6 +30,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
|
|||
// Same for `is_duplicate`: omit unless the Duplicates tab.
|
||||
// `no_suggestions`: Job Matching "No suggestions" tab — unassigned + empty
|
||||
// suggested_job_post_ids. Omit unless that tab.
|
||||
// `processing_state`: Processed / Rejected tabs (Move to Shortlist writes
|
||||
// processed, not application_status PROCESS).
|
||||
params: {
|
||||
search,
|
||||
top,
|
||||
|
|
@ -40,6 +42,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
|
|||
assigned,
|
||||
is_duplicate: isDuplicate,
|
||||
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
|
||||
* 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', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
|
|
@ -132,6 +135,7 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned,
|
|||
application_status: applicationStatus,
|
||||
assigned,
|
||||
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. */
|
||||
export function listRange({ fromDate, toDate, status, top = 200, skip } = {}) {
|
||||
export function listRange({ fromDate, toDate, status, recruiterId, top = 200, skip } = {}) {
|
||||
return request('/interview/fetch', {
|
||||
params: {
|
||||
from_date: fromDate,
|
||||
to_date: toDate,
|
||||
status,
|
||||
recruiter_id: recruiterId,
|
||||
top,
|
||||
skip,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
|
|||
* picker payload does not.
|
||||
*/
|
||||
export function list({ search, department, requisitionStatus, employmentType,
|
||||
top, skip, activeOnly } = {}) {
|
||||
hiringManagerId, top, skip, activeOnly } = {}) {
|
||||
return request('/jobs/fetch', {
|
||||
params: {
|
||||
search,
|
||||
department,
|
||||
requisition_status: requisitionStatus,
|
||||
employment_type: employmentType,
|
||||
hiring_manager_id: hiringManagerId,
|
||||
top,
|
||||
skip,
|
||||
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
|
||||
the Buffer publishing lifecycle — never map the two onto one badge. */
|
||||
const REQ_STATUS_LABEL = { open: 'Open', closed: 'Closed', on_hold: 'On Hold' }
|
||||
export const JOB_STATUSES = Object.values(REQ_STATUS_LABEL)
|
||||
the Buffer publishing lifecycle — never map the two onto one badge. Fallback
|
||||
matches GET /jobs/requisition-statuses/fetch so the dropdown still works if
|
||||
that call 403s. */
|
||||
export const REQUISITION_STATUSES = [
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'on_hold', label: 'On Hold' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
]
|
||||
const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label]))
|
||||
const LABEL_TO_STATUS = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.label, s.value]))
|
||||
export const JOB_STATUSES = REQUISITION_STATUSES.map((s) => s.label)
|
||||
|
||||
export function listRequisitionStatuses() {
|
||||
return request('/jobs/requisition-statuses/fetch')
|
||||
}
|
||||
|
||||
function experienceLabel(min, max) {
|
||||
if (min == null && max == null) return null
|
||||
|
|
@ -48,6 +62,8 @@ export function toJobView(row) {
|
|||
publishStatus: row.status,
|
||||
recruiter: row.recruiter_name,
|
||||
recruiterId: row.current_recruiter_id,
|
||||
hiringManager: row.hiring_manager_name,
|
||||
hiringManagerId: row.hiring_manager_id,
|
||||
createdByName: row.created_by_name,
|
||||
applicantCount: row.applicant_count ?? 0,
|
||||
// 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
|
||||
* (jobs.export). Same filters as list(); `status` takes the UI label.
|
||||
|
|
@ -118,3 +132,8 @@ export function setStatus(jobPostId, status) {
|
|||
body: { requisition_status },
|
||||
})
|
||||
}
|
||||
|
||||
/** Status-change audit for one requisition — GET /jobs/status-history/fetch. */
|
||||
export function listStatusHistory(jobPostId) {
|
||||
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,40 +15,43 @@ import { request } from '../lib/apiClient'
|
|||
/**
|
||||
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
||||
*
|
||||
* The enum has 11 values and the board 7 columns, so this is deliberately
|
||||
* many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere)
|
||||
* and reads as Shortlist rather than as an outcome, ONHOLD parks in Screening, and
|
||||
* APPROVED is the pre-HIRED spelling of a hire.
|
||||
* The enum has 11 values. Approved and On Hold are first-class columns (they
|
||||
* used to be folded into Hired / Screening). CLOSED is the inbox default for
|
||||
* an application that did not progress — it reads as Rejected, same as the
|
||||
* board's Rejected column, not as Shortlist.
|
||||
*
|
||||
* Anything unmapped falls through to Shortlist rather than vanishing from the
|
||||
* board — a card with no column is a candidate nobody sees.
|
||||
*/
|
||||
export const STAGE_FROM_STATUS = {
|
||||
PENDING: 'Shortlist',
|
||||
CLOSED: 'Shortlist',
|
||||
PROCESS: 'Screening',
|
||||
ONHOLD: 'Screening',
|
||||
SCREENING: 'Screening',
|
||||
ONHOLD: 'On Hold',
|
||||
ASSESSMENT: 'Assessment',
|
||||
INTERVIEW: 'Interview',
|
||||
OFFER: 'Offer',
|
||||
APPROVED: 'Approved',
|
||||
HIRED: 'Hired',
|
||||
APPROVED: 'Hired',
|
||||
CLOSED: 'Rejected',
|
||||
REJECTED: 'Rejected',
|
||||
}
|
||||
|
||||
/**
|
||||
* Column -> the status WRITTEN on a drop. Not the inverse of the map above: the
|
||||
* legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are
|
||||
* never written, so the vocabulary converges on the canonical value as cards get
|
||||
* moved. Shortlist writes PENDING because the enum has no SHORTLIST member.
|
||||
* Column -> the status WRITTEN on a drop. Not the inverse of the map above:
|
||||
* PROCESS is readable as Screening but is never written, so the vocabulary
|
||||
* converges on the canonical value as cards get moved. Shortlist writes
|
||||
* 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 = {
|
||||
Shortlist: 'PENDING',
|
||||
Screening: 'SCREENING',
|
||||
'On Hold': 'ONHOLD',
|
||||
Assessment: 'ASSESSMENT',
|
||||
Interview: 'INTERVIEW',
|
||||
Offer: 'OFFER',
|
||||
Approved: 'APPROVED',
|
||||
Hired: 'HIRED',
|
||||
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.
|
||||
*/
|
||||
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 } })
|
||||
}
|
||||
|
||||
export function listManagers() {
|
||||
return request('/managers/fetch')
|
||||
}
|
||||
|
||||
export function listPendingApprovals() {
|
||||
return request('/users/pending-approvals')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const ROUTES = [
|
|||
// --- Workspace ---
|
||||
{ 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: '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: 'candidates', title: 'Candidates', icon: 'users', 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 { qk } from '../lib/queryKeys'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
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
|
||||
* completing a task updates the badge with no call site involved at all.
|
||||
*
|
||||
* `matching` is the unassigned applications queue — the one number Job Matching
|
||||
* exists to drive to zero.
|
||||
* `matching` is the unassigned CV-bank queue — CVs imported with No job that
|
||||
* still have no job_post_id. Job Matching exists to drive that number to zero.
|
||||
*/
|
||||
export function useBadges() {
|
||||
const { data: matchingTotal = 0 } = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ assigned: false }),
|
||||
queryKey: qk.candidates.matching({ assigned: false, count: true }),
|
||||
queryFn: async () => {
|
||||
const res = await inboxApi.listApplications({ assigned: false, top: 1 })
|
||||
return res?.total ?? 0
|
||||
try {
|
||||
const res = await candidatesApi.listMatching({ assigned: false, top: 1 })
|
||||
return res?.total ?? 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
},
|
||||
})
|
||||
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 jobStatuses = ['Open', 'On Hold', 'Closed', 'Draft'];
|
||||
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 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;
|
||||
segs.length = 0;
|
||||
data.forEach((v, i) => {
|
||||
if (!v) return
|
||||
const a1 = a0 + (v / total) * Math.PI * 2 * prog;
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy);
|
||||
ctx.arc(cx, cy, r, a0, a1); ctx.closePath();
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export const qk = {
|
|||
managers: {
|
||||
all: () => ['managers'],
|
||||
list: (p = {}) => ['managers', 'list', p],
|
||||
directory: () => ['managers', 'directory'],
|
||||
},
|
||||
orgSettings: {
|
||||
all: () => ['orgSettings'],
|
||||
|
|
@ -78,6 +79,8 @@ export const qk = {
|
|||
jobs: {
|
||||
all: () => ['jobs'],
|
||||
list: (p = {}) => ['jobs', 'list', p],
|
||||
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
||||
statusHistory: (id) => ['jobs', 'status-history', id],
|
||||
},
|
||||
talent: {
|
||||
all: () => ['talent'],
|
||||
|
|
@ -92,6 +95,8 @@ export const qk = {
|
|||
count: (p = {}) => ['candidates', 'count', p],
|
||||
detail: (id) => ['candidates', 'detail', id],
|
||||
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
|
||||
// MAPPED (kanban cards, not the raw envelope), so they need their own key —
|
||||
|
|
|
|||
|
|
@ -342,10 +342,10 @@ export default function Analytics() {
|
|||
}
|
||||
}, [offersQuery.data])
|
||||
|
||||
/* The funnel has 11 statuses; REJECTED is dropped because it is an outcome,
|
||||
not a stage, and its volume flattens every other bar. */
|
||||
/* Fold the 11 enum statuses onto the 7 pipeline columns; REJECTED is dropped
|
||||
because it is an outcome, not a stage, and its volume flattens every other bar. */
|
||||
const pipeline = useMemo(() => {
|
||||
const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED')
|
||||
const rows = analyticsApi.toBoardStageRows(funnelQuery.data ?? [])
|
||||
return {
|
||||
labels: rows.map((p) => p.stage),
|
||||
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). */
|
||||
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History']
|
||||
// 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 REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
|
||||
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
||||
|
|
@ -213,6 +213,7 @@ export default function CandidateProfile({
|
|||
onDone: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.forms.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -281,7 +282,7 @@ export default function CandidateProfile({
|
|||
{advanceLive.isPending
|
||||
? 'Moving…'
|
||||
: nextStage ? `Advance to ${nextStage}`
|
||||
: stageLabel === 'Rejected' ? 'Rejected' : 'Pipeline complete'}
|
||||
: stageLabel === 'Rejected' || stageLabel === 'On Hold' ? stageLabel : 'Pipeline complete'}
|
||||
</button>
|
||||
) : (
|
||||
<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: '' }
|
||||
|
||||
/** 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). */
|
||||
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.
|
||||
This list is the bank's home: browse, download, or remove; picking a CV up
|
||||
for a job later is a future action. */
|
||||
/* The stored-CV bank — CVs imported with No job. Unassigned rows live here;
|
||||
assigning a job in Job Matching sets job_post_id and they leave this list. */
|
||||
function CvBank() {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
|
|
@ -432,6 +431,11 @@ function CvBank() {
|
|||
{r.candidate_email || 'No email detected'}
|
||||
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
|
||||
</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 ? (
|
||||
<div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}>
|
||||
{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 interviewsApi from '../api/interviews'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
|
||||
const POLL_MS = 60_000
|
||||
const BOARD_ACTIVE = [
|
||||
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||
'Approved', 'Hired', 'On Hold', 'Rejected',
|
||||
]
|
||||
|
||||
function asObject(data) {
|
||||
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 }
|
||||
}
|
||||
|
||||
/* 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()) {
|
||||
const h = now.getHours()
|
||||
|
|
@ -200,9 +194,18 @@ export default function Dashboard() {
|
|||
},
|
||||
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({
|
||||
queryKey: qk.analytics.funnel(),
|
||||
queryFn: async () => asList((await analyticsApi.funnel()).data),
|
||||
queryKey: qk.pipeline.board({ scope: 'dashboard' }),
|
||||
queryFn: async () => {
|
||||
const res = await pipelineApi.listApplications({ limit: 1 })
|
||||
return pipelineApi.toStageCounts(res?.counts?.by_status)
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchInterval: POLL_MS,
|
||||
})
|
||||
|
||||
|
|
@ -278,21 +281,20 @@ export default function Dashboard() {
|
|||
[trendQuery.data],
|
||||
)
|
||||
|
||||
/* "Active by stage" means exactly that: REJECTED is excluded (matching the
|
||||
Analytics screen's pipeline card), and each bar is that stage's share of
|
||||
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. */
|
||||
/* Occupied board columns only. Zero-count stages in the doughnut are a
|
||||
canvas footgun (arc(a,a) can paint a full circle and hide Screening). */
|
||||
const pipeRows = useMemo(() => {
|
||||
const rows = asList(funnelQuery.data)
|
||||
.filter((r) => r.stage !== 'REJECTED')
|
||||
.sort((a, b) => stageRank(a.stage) - stageRank(b.stage))
|
||||
const total = rows.reduce((sum, r) => sum + (r.count || 0), 0)
|
||||
const folded = funnelQuery.data && typeof funnelQuery.data === 'object'
|
||||
? funnelQuery.data
|
||||
: {}
|
||||
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
|
||||
return rows.map((r, i) => ({
|
||||
stage: r.stage,
|
||||
count: r.count,
|
||||
pct: total ? Math.round(((r.count || 0) / total) * 100) : 0,
|
||||
...r,
|
||||
pct: total ? Math.round((r.count / total) * 100) : 0,
|
||||
color: pal[i % pal.length],
|
||||
}))
|
||||
}, [funnelQuery.data])
|
||||
|
|
@ -300,8 +302,8 @@ export default function Dashboard() {
|
|||
const pipelineDoughnut = useMemo(() => ({
|
||||
labels: pipeRows.map((p) => p.stage),
|
||||
data: pipeRows.map((p) => p.count),
|
||||
colors: Charts.PALETTE,
|
||||
centerValue: pipeRows.reduce((sum, s) => sum + (s.count || 0), 0),
|
||||
colors: pipeRows.map((p) => p.color),
|
||||
centerValue: pipeRows.reduce((sum, s) => sum + s.count, 0),
|
||||
centerLabel: 'In pipeline',
|
||||
}), [pipeRows])
|
||||
|
||||
|
|
@ -453,16 +455,16 @@ export default function Dashboard() {
|
|||
<div>
|
||||
<h3>Candidate Pipeline</h3>
|
||||
<span className="ch-sub">
|
||||
{funnelQuery.isPending ? 'Loading…' : 'Active by stage, rejections excluded'}
|
||||
{funnelQuery.isPending ? 'Loading…' : 'Active by stage'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{funnelQuery.isError ? (
|
||||
<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>
|
||||
) : pipeRows.length === 0 && funnelQuery.isSuccess ? (
|
||||
) : funnelQuery.isSuccess && pipeRows.length === 0 ? (
|
||||
<EmptyState icon="inbox" title="No pipeline data yet">
|
||||
Stage counts appear once applications are in the system.
|
||||
</EmptyState>
|
||||
|
|
@ -478,7 +480,7 @@ export default function Dashboard() {
|
|||
style={{ width: `${r.pct}%`, background: r.color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="pipe-pct">{r.pct}%</span>
|
||||
<span className="pipe-pct" title={`${r.pct}%`}>{r.count}</span>
|
||||
</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' }
|
||||
|
||||
/**
|
||||
* Server-side filters for each tab. Email Processed / Rejected use
|
||||
* Candidate_application_Status (PROCESS / REJECTED). Sheet Forms use
|
||||
* form_data.processing_state (same vocabulary as inbox Import/Reject).
|
||||
* Server-side filters for each tab. Email Processed / Rejected follow
|
||||
* processing_state (the same writes as Import / Shortlist / Reject). Sheet
|
||||
* Forms use form_data.processing_state.
|
||||
*/
|
||||
const TAB_FILTERS = {
|
||||
Unread: { isread: false },
|
||||
Processed: { applicationStatus: 'PROCESS' },
|
||||
Rejected: { applicationStatus: 'REJECTED' },
|
||||
Processed: { processingState: 'processed' },
|
||||
Rejected: { processingState: 'rejected' },
|
||||
Duplicates: { isDuplicate: true },
|
||||
}
|
||||
|
||||
|
|
@ -224,9 +224,9 @@ function SourceChip({ item }) {
|
|||
// The dot carries the partner's brand colour; the label uses theme text —
|
||||
// 11px labels in the partner colour failed AA in both themes.
|
||||
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" />
|
||||
{item.source}
|
||||
<span className="source-chip-label">{item.source}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -904,6 +904,7 @@ export default function Inbox() {
|
|||
qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
|
||||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -1104,6 +1105,17 @@ export default function Inbox() {
|
|||
)}
|
||||
</div>
|
||||
<div className="ii-pos">{i.position}</div>
|
||||
<div className="ii-aside">
|
||||
<div className="ii-time">
|
||||
{outlookListTime(i.received)}
|
||||
</div>
|
||||
{i.atsScore != null && (
|
||||
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
|
||||
)}
|
||||
{isForms && i.noticePeriod && (
|
||||
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ii-meta">
|
||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
|
||||
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||||
|
|
@ -1114,17 +1126,6 @@ export default function Inbox() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div className="ii-time">
|
||||
{outlookListTime(i.received)}
|
||||
</div>
|
||||
{i.atsScore != null && (
|
||||
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
|
||||
)}
|
||||
{isForms && i.noticePeriod && (
|
||||
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
@ -1310,6 +1311,7 @@ function FormApplicantDetail({
|
|||
qc.invalidateQueries({ queryKey: qk.mailbox.formRow(vars.recordId) })
|
||||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -1648,6 +1650,8 @@ function ApplicationDetail({
|
|||
onSettled: (_res, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||
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 Modal from '../ui/Modal'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
|
|
@ -26,7 +27,7 @@ import * as jobsApi from '../api/jobs'
|
|||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as assignmentsApi from '../api/assignments'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import { JOB_STATUSES } from '../api/jobs'
|
||||
import * as usersApi from '../api/users'
|
||||
import { empTypes, fmtShort } from '../data/seed'
|
||||
|
||||
// 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 jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data])
|
||||
const statusesQuery = useQuery({
|
||||
queryKey: qk.jobs.requisitionStatuses(),
|
||||
queryFn: async () => {
|
||||
const res = await jobsApi.listRequisitionStatuses()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.length ? rows : jobsApi.REQUISITION_STATUSES
|
||||
},
|
||||
})
|
||||
const statusLabels = useMemo(
|
||||
() => (statusesQuery.data ?? jobsApi.REQUISITION_STATUSES).map((s) => s.label),
|
||||
[statusesQuery.data],
|
||||
)
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [dept, setDept] = useState('')
|
||||
|
|
@ -90,12 +103,19 @@ export default function Jobs() {
|
|||
const canDelete = can('jobs.delete')
|
||||
|
||||
// Deep-link intents from global search, the dashboard and the manager portal.
|
||||
// Consume once and replace history state: jobs refetch after a status PATCH
|
||||
// used to replay openCreate and pop the create modal over the detail view.
|
||||
useEffect(() => {
|
||||
const st = location.state
|
||||
if (!st) return
|
||||
if (!st?.openCreate && !st?.openJob) return
|
||||
if (st.openCreate) setCreating(true)
|
||||
if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null)
|
||||
}, [location.state, jobs])
|
||||
if (st.openJob) {
|
||||
const job = jobs.find((j) => j.id === st.openJob)
|
||||
if (job) setViewing(job)
|
||||
else if (!jobsQuery.isSuccess) return
|
||||
}
|
||||
navigate('.', { replace: true, state: null })
|
||||
}, [location.state, jobs, jobsQuery.isSuccess, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewing) return
|
||||
|
|
@ -183,7 +203,7 @@ export default function Jobs() {
|
|||
if (type && j.type !== type) return false
|
||||
if (q) {
|
||||
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)
|
||||
.join(' ')
|
||||
.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: '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: '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,
|
||||
sortValue: (j) => (j.created ? j.created.getTime() : 0),
|
||||
|
|
@ -235,17 +257,17 @@ export default function Jobs() {
|
|||
key: '_a', label: 'Actions', align: 'right',
|
||||
render: (j) => (
|
||||
<div className="row-actions">
|
||||
<button className="act-btn" data-tip="View" aria-label="View job" onClick={() => setViewing(j)}><Icon name="eye" /></button>
|
||||
<button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); setViewing(j) }}><Icon name="eye" /></button>
|
||||
{canEdit && (
|
||||
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={() => setEditing(j)}><Icon name="edit" /></button>
|
||||
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button>
|
||||
)}
|
||||
{canEdit && (j.status === 'Closed' ? (
|
||||
{canEdit && (j.status === 'Closed' || j.status === 'Completed' ? (
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip="Reopen"
|
||||
aria-label="Reopen job"
|
||||
disabled={setJobStatus.isPending}
|
||||
onClick={() => setJobStatus.mutate({ id: j.id, status: 'Open' })}
|
||||
onClick={(e) => { e.stopPropagation(); setJobStatus.mutate({ id: j.id, status: 'Open' }) }}
|
||||
><Icon name="refresh" /></button>
|
||||
) : (
|
||||
<button
|
||||
|
|
@ -253,14 +275,15 @@ export default function Jobs() {
|
|||
data-tip="Close job"
|
||||
aria-label="Close job"
|
||||
disabled={setJobStatus.isPending}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm(`Close “${j.title}”? It stays on the board and can be reopened later.`)) {
|
||||
setJobStatus.mutate({ id: j.id, status: 'Closed' })
|
||||
}
|
||||
}}
|
||||
><Icon name="x-circle" /></button>
|
||||
))}
|
||||
<button className="act-btn" data-tip="Publish" aria-label="Publish job" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
|
||||
<button className="act-btn" data-tip="Publish" aria-label="Publish job" onClick={(e) => { e.stopPropagation(); navigate('/jobboard', { state: { publishJob: j.id } }) }}><Icon name="send" /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -310,7 +333,7 @@ export default function Jobs() {
|
|||
</select>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All Status</option>
|
||||
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
|
||||
{statusLabels.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="">All Types</option>
|
||||
|
|
@ -323,6 +346,7 @@ export default function Jobs() {
|
|||
rows={rows}
|
||||
pageSize={8}
|
||||
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 } }) }}
|
||||
onEdit={() => { setEditing(viewing); setViewing(null) }}
|
||||
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
|
||||
statusLabels={statusLabels}
|
||||
onDelete={() => {
|
||||
if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id)
|
||||
}}
|
||||
|
|
@ -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 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 }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
const form = useFormState({
|
||||
hiring_manager_id: '',
|
||||
current_recruiter_id: '',
|
||||
title: '',
|
||||
department: '',
|
||||
location: '',
|
||||
|
|
@ -432,6 +568,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
const v = form.values
|
||||
const errors = {}
|
||||
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)
|
||||
if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1'
|
||||
const expMin = v.experience_min === '' ? null : Number(v.experience_min)
|
||||
|
|
@ -462,6 +599,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
requirements: splitLines(v.requirements),
|
||||
optional_skills: splitLines(v.optional_skills),
|
||||
description: v.description.trim() || null,
|
||||
hiring_manager_id: v.hiring_manager_id,
|
||||
current_recruiter_id: v.current_recruiter_id || undefined,
|
||||
}, imageFile)
|
||||
}
|
||||
|
||||
|
|
@ -522,6 +661,39 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
<FieldError>{form.errors.title}</FieldError>
|
||||
</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="field-label-row">
|
||||
<label>Department</label>
|
||||
|
|
@ -671,6 +843,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
}
|
||||
|
||||
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
const form = useFormState({
|
||||
title: j.title || '',
|
||||
department: j.department || '',
|
||||
|
|
@ -680,6 +854,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
experience_min: j.experienceMin != null ? String(j.experienceMin) : '',
|
||||
experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
|
||||
description: j.description || '',
|
||||
hiring_manager_id: j.hiringManagerId || '',
|
||||
current_recruiter_id: j.recruiterId || '',
|
||||
})
|
||||
|
||||
const assistContext = () => ({
|
||||
|
|
@ -707,10 +883,11 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
function submit() {
|
||||
if (busy) return
|
||||
const title = form.values.title.trim()
|
||||
if (!title) {
|
||||
form.setErrors({ title: 'Job title is required' })
|
||||
return
|
||||
}
|
||||
const errors = {}
|
||||
if (!title) errors.title = 'Job title is required'
|
||||
if (!form.values.hiring_manager_id) errors.hiring_manager_id = 'Hiring manager is required'
|
||||
form.setErrors(errors)
|
||||
if (Object.keys(errors).length) return
|
||||
onSubmit({
|
||||
title,
|
||||
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_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
|
||||
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} />
|
||||
<FieldError>{form.errors.title}</FieldError>
|
||||
</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="field-label-row">
|
||||
<label>Department</label>
|
||||
|
|
@ -796,122 +1001,170 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Recruiter ownership of one requisition — GET/POST /job/assignments/*.
|
||||
*
|
||||
* 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.
|
||||
* Hiring-manager + recruiter pointers on one requisition.
|
||||
* Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
|
||||
*/
|
||||
function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) {
|
||||
function JobOwnership({ job, canEdit }) {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const [picked, setPicked] = useState('')
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
|
||||
const assigneesQuery = useQuery({
|
||||
queryKey: qk.tasks.assignees(),
|
||||
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 }),
|
||||
const patch = useMutation({
|
||||
mutationFn: (body) => jobsApi.update(job.id, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.assignments.job(jobPostId) })
|
||||
qc.invalidateQueries({ queryKey: qk.assignments.job(job.id) })
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
setPicked('')
|
||||
toast('Recruiter assigned', 'success')
|
||||
qc.invalidateQueries({ queryKey: qk.managers.all() })
|
||||
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 (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div className="mb-16">
|
||||
<div style={SECTION_LABEL}>Recruiter ownership</div>
|
||||
{currentQuery.isError ? (
|
||||
<p className="text-muted text-sm">
|
||||
{friendlyAuthError(currentQuery.error, 'Assignments did not load.')}
|
||||
{' '}Needs the <code>jobs.view</code> permission.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted text-sm" style={{ marginBottom: canEdit ? 10 : 0 }}>
|
||||
{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 style={SECTION_LABEL}>Ownership</div>
|
||||
<div className="form-grid" style={{ marginBottom: 12 }}>
|
||||
<div className="form-field">
|
||||
<label>Hiring manager</label>
|
||||
{canEdit ? (
|
||||
<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">{job.hiringManager || '—'}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && assigneesQuery.isError && (
|
||||
<p className="text-muted text-sm">
|
||||
The recruiter list needs the <code>tasks.view</code> permission.
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && recruitersQuery.isError && (
|
||||
<p className="text-muted text-sm">The recruiter list needs the <code>tasks.view</code> permission.</p>
|
||||
)}
|
||||
</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
|
||||
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
||||
simply renders nothing. */
|
||||
|
|
@ -935,7 +1188,30 @@ function JobCover({ jobId }) {
|
|||
|
||||
function JobDetail({
|
||||
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
||||
statusLabels = jobsApi.JOB_STATUSES,
|
||||
}) {
|
||||
const [tab, setTab] = useState('details')
|
||||
const historyQuery = useQuery({
|
||||
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 (
|
||||
<Modal
|
||||
title="Job Details"
|
||||
|
|
@ -975,7 +1251,7 @@ function JobDetail({
|
|||
disabled={statusBusy}
|
||||
onChange={(e) => onStatus(e.target.value)}
|
||||
>
|
||||
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
|
||||
{statusLabels.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<Badge>{j.status}</Badge>
|
||||
|
|
@ -983,36 +1259,52 @@ function JobDetail({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<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">Location</div><div className="iv">{j.location || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</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 by</div><div className="iv">{j.createdByName || '—'}</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>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
{ key: 'details', label: 'Details' },
|
||||
{ key: 'history', label: 'History', count: historyCount || undefined },
|
||||
]}
|
||||
/>
|
||||
|
||||
<RecruiterAssignment jobPostId={j.id} fallbackName={j.recruiter} canEdit={canEdit} />
|
||||
|
||||
{j.description && (
|
||||
{tab === 'details' && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div className="mb-16">
|
||||
<div style={SECTION_LABEL}>Description</div>
|
||||
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
|
||||
<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">Location</div><div className="iv">{j.location || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</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 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">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
|
||||
</div>
|
||||
|
||||
<JobOwnership job={j} canEdit={canEdit} />
|
||||
|
||||
{j.description && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div className="mb-16">
|
||||
<div style={SECTION_LABEL}>Description</div>
|
||||
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!!(j.skills && j.skills.length) && (
|
||||
<div>
|
||||
<div style={SECTION_LABEL}>Required Skills</div>
|
||||
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!!(j.skills && j.skills.length) && (
|
||||
<div>
|
||||
<div style={SECTION_LABEL}>Required Skills</div>
|
||||
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
|
||||
|
|
@ -53,6 +53,16 @@ export default function Managers() {
|
|||
const managers = managersQuery.data?.rows ?? []
|
||||
const total = managersQuery.data?.total ?? 0
|
||||
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 pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(page, pages)
|
||||
|
|
@ -100,7 +110,7 @@ export default function Managers() {
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="divider" style={{ margin: '12px 0' }} />
|
||||
|
|
@ -149,6 +159,16 @@ export default function Managers() {
|
|||
|
||||
function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast }) {
|
||||
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({
|
||||
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'),
|
||||
|
|
@ -234,8 +254,8 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })
|
|||
) : (
|
||||
<>
|
||||
<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">{jobs.length}</span><span className="stat-mini-lbl">Open Jobs</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">{mine.length}</span><span className="stat-mini-lbl">Jobs</span></div>
|
||||
<div className="stat-mini">
|
||||
<span className="stat-mini-val">{m.email ? 'Yes' : '—'}</span>
|
||||
<span className="stat-mini-lbl">Email on file</span>
|
||||
|
|
@ -259,14 +279,13 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })
|
|||
</div>
|
||||
|
||||
<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">
|
||||
{jobs.filter((j) => j.status === 'Open').length === 0 ? (
|
||||
<p className="text-muted">No open requisitions</p>
|
||||
{mineQuery.isPending ? (
|
||||
<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
|
||||
key={j.id}
|
||||
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;
|
||||
skip = (page-1)*limit, same as Inbox. The AI's suggested_job_post_ids land
|
||||
here as a radiogroup; Assign writes assigned_job_post_id. Nothing is
|
||||
marked read — that stays Inbox's job so this page cannot silently move the
|
||||
inbox nav badge.
|
||||
Needs assignment: job_post_id is null. Assign writes job_post_id (and a
|
||||
candidate user) so Pipeline / Candidates fetch the row like any other
|
||||
manual upload. Assigned tab is the same origin with a job already linked.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
||||
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
|
|
@ -23,34 +21,26 @@ import { useToast } from '../ui/Toast'
|
|||
import { useAuth } from '../auth/AuthContext'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
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 s3Api from '../api/s3'
|
||||
import {
|
||||
avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta,
|
||||
} from '../data/seed'
|
||||
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'needs', label: 'Needs assignment' },
|
||||
{ key: 'assigned', label: 'Assigned' },
|
||||
{ key: 'none', label: 'No suggestions' },
|
||||
{ key: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
const TAB_FILTERS = {
|
||||
needs: { assigned: false },
|
||||
assigned: { assigned: true },
|
||||
none: { assigned: false, noSuggestions: true },
|
||||
all: {},
|
||||
}
|
||||
|
||||
/** Same cap as GET /inbox/all-applications `top`. */
|
||||
const PAGE_SIZE_MAX = 500
|
||||
|
||||
const RESUME_STATUS = {
|
||||
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
|
||||
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
|
||||
}
|
||||
const CV_BANK_META = { icon: 'file', color: 'var(--c2)', channel: 'Upload' }
|
||||
|
||||
function parseDate(value) {
|
||||
if (!value) return null
|
||||
|
|
@ -58,13 +48,39 @@ function parseDate(value) {
|
|||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function sourceFrom(messageTo) {
|
||||
const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim()
|
||||
if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null }
|
||||
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
|
||||
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
|
||||
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
|
||||
return { source: raw.split(',')[0].trim(), sourceMeta: null }
|
||||
function mapRow(row) {
|
||||
const name = row.name || row.email || row.file_name || 'Unknown'
|
||||
return {
|
||||
id: String(row.id),
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
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 }) {
|
||||
|
|
@ -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 }) {
|
||||
if (item.matchStatus === 'processing') {
|
||||
return <Badge className="b-gray">Matching…</Badge>
|
||||
}
|
||||
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>
|
||||
}
|
||||
const n = item.suggestedIds.length
|
||||
if (n > 0) return <Badge className="b-blue">{n} suggested</Badge>
|
||||
return <Badge className="b-amber">No match</Badge>
|
||||
return <Badge className="b-amber">No job</Badge>
|
||||
}
|
||||
|
||||
export default function Matching() {
|
||||
const { toast } = useToast()
|
||||
const { can } = useAuth()
|
||||
const canEdit = can('inbox.edit')
|
||||
const canEdit = can('candidates.edit')
|
||||
const qc = useQueryClient()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const deepLink = searchParams.get('record')
|
||||
|
|
@ -188,7 +116,6 @@ export default function Matching() {
|
|||
const [selection, setSelection] = useState(null)
|
||||
const [manualPost, setManualPost] = useState(null)
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [whyOpen, setWhyOpen] = useState(false)
|
||||
|
||||
const tabFilter = TAB_FILTERS[tab] ?? {}
|
||||
const listParams = useMemo(() => ({
|
||||
|
|
@ -199,25 +126,21 @@ export default function Matching() {
|
|||
}), [tabFilter, page, pageSize, q])
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ ...listParams, tab }),
|
||||
queryFn: () => fetchApplications(listParams),
|
||||
queryKey: qk.candidates.matching({ ...listParams, tab }),
|
||||
queryFn: () => fetchQueue(listParams),
|
||||
})
|
||||
|
||||
const needsCount = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ assigned: false, count: true }),
|
||||
queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0,
|
||||
queryKey: qk.candidates.matching({ assigned: false, count: true }),
|
||||
queryFn: async () => (await candidatesApi.listMatching({ assigned: false, top: 1 }))?.total ?? 0,
|
||||
})
|
||||
const assignedCount = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ assigned: true, count: true }),
|
||||
queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0,
|
||||
queryKey: qk.candidates.matching({ assigned: true, count: true }),
|
||||
queryFn: async () => (await candidatesApi.listMatching({ assigned: true, top: 1 }))?.total ?? 0,
|
||||
})
|
||||
const allCount = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ count: true }),
|
||||
queryFn: async () => (await inboxApi.listApplications({ 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,
|
||||
queryKey: qk.candidates.matching({ count: true }),
|
||||
queryFn: async () => (await candidatesApi.listMatching({ top: 1 }))?.total ?? 0,
|
||||
})
|
||||
|
||||
const rows = listQuery.data?.rows ?? []
|
||||
|
|
@ -226,13 +149,10 @@ export default function Matching() {
|
|||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(page, pages)
|
||||
|
||||
const noneCount = noneCountQuery.data ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
if (listQuery.isSuccess && page > pages) setPage(pages)
|
||||
}, [listQuery.isSuccess, page, pages])
|
||||
|
||||
// Preselect deep link once, then clear the query so refresh doesn't re-pin.
|
||||
useEffect(() => {
|
||||
if (!deepLink) return undefined
|
||||
setSelectedId(deepLink)
|
||||
|
|
@ -241,7 +161,7 @@ export default function Matching() {
|
|||
}, [deepLink, setSearchParams])
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: qk.mailbox.message(selectedId),
|
||||
queryKey: qk.candidates.matchingDetail(selectedId),
|
||||
queryFn: () => fetchDetail(selectedId),
|
||||
enabled: Boolean(selectedId),
|
||||
})
|
||||
|
|
@ -249,12 +169,10 @@ export default function Matching() {
|
|||
const detail = detailQuery.data
|
||||
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 ids = new Set()
|
||||
for (const r of rows) {
|
||||
if (r.assignedId) ids.add(r.assignedId)
|
||||
for (const id of r.suggestedIds) ids.add(id)
|
||||
}
|
||||
return [...ids]
|
||||
}, [rows])
|
||||
|
|
@ -275,24 +193,11 @@ export default function Matching() {
|
|||
return map
|
||||
}, [titlesQuery.data])
|
||||
|
||||
// Reset local selection when the selected application changes.
|
||||
useEffect(() => {
|
||||
setManualPost(null)
|
||||
setWhyOpen(false)
|
||||
if (detail?.assignedId) setSelection(detail.assignedId)
|
||||
else if (detail?.suggestedIds?.[0]) setSelection(detail.suggestedIds[0])
|
||||
else setSelection(null)
|
||||
}, [detail?.id, detail?.assignedId, detail?.suggestedIds])
|
||||
|
||||
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])
|
||||
}, [detail?.id, detail?.assignedId])
|
||||
|
||||
const selectedPost = useMemo(() => {
|
||||
if (!selection) return null
|
||||
|
|
@ -300,17 +205,11 @@ export default function Matching() {
|
|||
if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) {
|
||||
return detail.assignedPost
|
||||
}
|
||||
const hit = suggestionCards.find((c) => String(c.post.id) === String(selection))
|
||||
return hit?.post || null
|
||||
}, [selection, manualPost, detail, suggestionCards])
|
||||
return null
|
||||
}, [selection, manualPost, detail])
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId),
|
||||
onMutate: async ({ recordId, jobPostId }) => {
|
||||
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
|
||||
await qc.cancelQueries({ queryKey: ['mailbox', 'assignments'] })
|
||||
return { recordId, jobPostId }
|
||||
},
|
||||
mutationFn: ({ recordId, jobPostId }) => candidatesApi.assignMatchingJob(recordId, jobPostId),
|
||||
onError: (err) => {
|
||||
toast(friendlyAuthError(err, 'Could not assign job post.'), 'error')
|
||||
},
|
||||
|
|
@ -321,12 +220,12 @@ export default function Matching() {
|
|||
else toast(`${name} unassigned`, 'success')
|
||||
},
|
||||
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.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) {
|
||||
const idx = filtered.findIndex((r) => r.id === vars.recordId)
|
||||
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(() => {
|
||||
const onKey = (e) => {
|
||||
const tag = e.target?.tagName
|
||||
|
|
@ -360,9 +248,6 @@ export default function Matching() {
|
|||
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||
const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))]
|
||||
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) {
|
||||
e.preventDefault()
|
||||
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
|
||||
|
|
@ -373,27 +258,25 @@ export default function Matching() {
|
|||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [filtered, selectedId, suggestionCards, canEdit, selection, detail, assignMutation])
|
||||
}, [filtered, selectedId, canEdit, selection, detail, assignMutation])
|
||||
|
||||
const counts = {
|
||||
needs: needsCount.data ?? 0,
|
||||
assigned: assignedCount.data ?? 0,
|
||||
none: noneCount,
|
||||
all: allCount.data ?? 0,
|
||||
}
|
||||
|
||||
const resumeText = detail?.resumeText || listRow?.resumeText || ''
|
||||
const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow)
|
||||
const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus)
|
||||
const resumeText = detail?.resumeText || listRow?.resumeText || ''
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="alert alert-danger mb-12">
|
||||
Your account does not hold <code>inbox.edit</code>, which the server requires to
|
||||
assign, unassign, or retry a match. Controls below stay disabled.
|
||||
Your account does not hold <code>candidates.edit</code>, which the server requires to
|
||||
assign or unassign a role. Controls below stay disabled.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -425,7 +308,7 @@ export default function Matching() {
|
|||
</div>
|
||||
<div>
|
||||
{listQuery.isPending && (
|
||||
<EmptyState icon="target" title="Loading…">Fetching applications.</EmptyState>
|
||||
<EmptyState icon="target" title="Loading…">Fetching CVs from the bank.</EmptyState>
|
||||
)}
|
||||
{listQuery.isError && (
|
||||
<EmptyState icon="alert" title="Couldn’t load queue">
|
||||
|
|
@ -435,14 +318,14 @@ export default function Matching() {
|
|||
{listQuery.isSuccess && filtered.length === 0 && (
|
||||
<EmptyState icon="check-circle" title="Queue clear">
|
||||
{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.'}
|
||||
</EmptyState>
|
||||
)}
|
||||
{filtered.map((i) => (
|
||||
<div
|
||||
key={i.id}
|
||||
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
|
||||
className={`inbox-item${selectedId === i.id ? ' active' : ''}`}
|
||||
onClick={() => setSelectedId(i.id)}
|
||||
>
|
||||
<Avatar name={i.name} initials={i.initials} color={i.color} />
|
||||
|
|
@ -480,13 +363,13 @@ export default function Matching() {
|
|||
<div className="split-detail">
|
||||
{!selectedId ? (
|
||||
<div style={{ padding: '100px 20px' }}>
|
||||
<EmptyState icon="target" title="Select an application">
|
||||
Choose an item from the list to review suggestions and assign a role.
|
||||
<EmptyState icon="target" title="Select a CV">
|
||||
Choose an item from the list to assign a job post.
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : detailQuery.isError ? (
|
||||
<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')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
|
@ -497,15 +380,9 @@ export default function Matching() {
|
|||
loading={detailQuery.isPending}
|
||||
canEdit={canEdit}
|
||||
selection={selection}
|
||||
setSelection={setSelection}
|
||||
manualPost={manualPost}
|
||||
suggestionCards={suggestionCards}
|
||||
selectedPost={selectedPost}
|
||||
resumeText={resumeText}
|
||||
resumeKey={resumeKey}
|
||||
whyOpen={whyOpen}
|
||||
setWhyOpen={setWhyOpen}
|
||||
matchFailed={matchFailed}
|
||||
onPickManual={() => setShowPicker(true)}
|
||||
onSkip={() => {
|
||||
const idx = filtered.findIndex((r) => r.id === selectedId)
|
||||
|
|
@ -521,9 +398,7 @@ export default function Matching() {
|
|||
assignMutation.mutate({ recordId: selectedId, jobPostId: null })
|
||||
}}
|
||||
onChange={() => setShowPicker(true)}
|
||||
onRematch={() => rematchMutation.mutate(selectedId)}
|
||||
assigning={assignMutation.isPending}
|
||||
rematching={rematchMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -549,23 +424,15 @@ function MatchingWorkspace({
|
|||
loading,
|
||||
canEdit,
|
||||
selection,
|
||||
setSelection,
|
||||
manualPost,
|
||||
suggestionCards,
|
||||
selectedPost,
|
||||
resumeText,
|
||||
resumeKey,
|
||||
whyOpen,
|
||||
setWhyOpen,
|
||||
matchFailed,
|
||||
onPickManual,
|
||||
onSkip,
|
||||
onAssign,
|
||||
onUnassign,
|
||||
onChange,
|
||||
onRematch,
|
||||
assigning,
|
||||
rematching,
|
||||
}) {
|
||||
const i = {
|
||||
name: detail?.name || listRow?.name || '…',
|
||||
|
|
@ -574,8 +441,9 @@ function MatchingWorkspace({
|
|||
position: detail?.position || listRow?.position,
|
||||
source: detail?.source || listRow?.source,
|
||||
sourceMeta: detail?.sourceMeta || listRow?.sourceMeta,
|
||||
processing: detail?.processing || listRow?.processing,
|
||||
resumeStatus: detail?.resumeStatus || listRow?.resumeStatus,
|
||||
email: detail?.email || listRow?.email || '',
|
||||
received: detail?.received || listRow?.received,
|
||||
linkedinUrl: detail?.linkedinUrl || listRow?.linkedinUrl || null,
|
||||
}
|
||||
|
||||
const assigned = detail?.assignedPost
|
||||
|
|
@ -588,20 +456,28 @@ function MatchingWorkspace({
|
|||
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
||||
<div className="flex-1">
|
||||
<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 }}>
|
||||
<SourceChip item={i} />{' '}
|
||||
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
||||
{i.resumeStatus}
|
||||
</Badge>
|
||||
{i.received && <span className="cell-sub">Added {fmtDate(i.received)}</span>}
|
||||
{loading && <span className="cell-sub">Loading details…</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{s3Api.canOpen(resumeKey) && (
|
||||
{(s3Api.canOpen(resumeKey) || i.linkedinUrl) && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -626,10 +502,10 @@ function MatchingWorkspace({
|
|||
</div>
|
||||
</div>
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -646,86 +522,29 @@ function MatchingWorkspace({
|
|||
}}
|
||||
>
|
||||
<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>
|
||||
{s3Api.canOpen(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 role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||||
<div className="fw-600 mb-8">Suggested roles</div>
|
||||
{suggestionCards.length === 0 && !manualPost ? (
|
||||
<EmptyState icon="alert" title="No suggested roles">
|
||||
<p>No job post was suggested. Choose a role manually.</p>
|
||||
<div role="radiogroup" aria-label="Job post" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||||
<div className="fw-600 mb-8">Job post</div>
|
||||
{!selectedPost ? (
|
||||
<EmptyState icon="briefcase" title="No job selected">
|
||||
<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 }}>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!canEdit || rematching}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={onRematch}
|
||||
>
|
||||
Retry match
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!canEdit}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
title={!canEdit ? 'Requires candidates.edit' : undefined}
|
||||
onClick={onPickManual}
|
||||
>
|
||||
Choose a role
|
||||
|
|
@ -733,24 +552,12 @@ function MatchingWorkspace({
|
|||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
suggestionCards.map(({ rank, post }) => (
|
||||
<JobCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
rank={rank}
|
||||
selected={String(selection) === String(post.id)}
|
||||
onSelect={(id) => setSelection(id)}
|
||||
resumeText={resumeText}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{manualPost && (
|
||||
<JobCard
|
||||
post={manualPost}
|
||||
post={selectedPost}
|
||||
rank={0}
|
||||
manual
|
||||
selected={String(selection) === String(manualPost.id)}
|
||||
onSelect={(id) => setSelection(id)}
|
||||
selected={String(selection) === String(selectedPost.id)}
|
||||
onSelect={() => {}}
|
||||
resumeText={resumeText}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -758,10 +565,10 @@ function MatchingWorkspace({
|
|||
className="btn btn-secondary"
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
disabled={!canEdit}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
title={!canEdit ? 'Requires candidates.edit' : undefined}
|
||||
onClick={onPickManual}
|
||||
>
|
||||
Choose a different role…
|
||||
{selectedPost ? 'Choose a different role…' : 'Choose a role…'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -780,11 +587,10 @@ function MatchingWorkspace({
|
|||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!canAssign}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
title={!canEdit ? 'Requires candidates.edit' : undefined}
|
||||
onClick={onAssign}
|
||||
>
|
||||
{selectedPost?.title
|
||||
? 'Assign' :'Assign'}
|
||||
Assign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export const KANBAN_STAGES = [
|
|||
{ name: 'Assessment', color: 'var(--stage-3)' },
|
||||
{ name: 'Interview', color: 'var(--stage-4)' },
|
||||
{ name: 'Offer', color: 'var(--stage-5)' },
|
||||
{ name: 'Approved', color: 'var(--stage-8)' },
|
||||
{ name: 'Hired', color: 'var(--stage-6)' },
|
||||
{ name: 'On Hold', color: 'var(--stage-9)' },
|
||||
{ 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
|
||||
// caches are stale too the moment this lands.
|
||||
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
|
||||
per recruiter — the interviews table has no recruiter column — and the card
|
||||
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 { 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 Charts from '../lib/charts'
|
||||
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 { friendlyAuthError } from '../lib/errors'
|
||||
import * as analyticsApi from '../api/analytics'
|
||||
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 WEEKS = 5
|
||||
|
|
@ -50,7 +66,12 @@ function weekdayIndex(date) {
|
|||
}
|
||||
|
||||
export default function RecruiterHub() {
|
||||
const { can } = useAuth()
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const [recruiterId, setRecruiterId] = useState('')
|
||||
const canViewTasks = can('tasks.view')
|
||||
const canEditTasks = can('tasks.edit')
|
||||
|
||||
const boardQuery = useQuery({
|
||||
queryKey: qk.analytics.recruiters({ top: 50, scope: 'hub' }),
|
||||
|
|
@ -86,6 +107,37 @@ export default function RecruiterHub() {
|
|||
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
|
||||
range so the request stays small however long the table gets. */
|
||||
const heatFrom = useMemo(() => {
|
||||
|
|
@ -96,16 +148,18 @@ export default function RecruiterHub() {
|
|||
}, [])
|
||||
|
||||
const heatQuery = useQuery({
|
||||
queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS }),
|
||||
queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS, recruiterId: activeId }),
|
||||
queryFn: async () => {
|
||||
const res = await interviewsApi.listRange({
|
||||
fromDate: heatFrom.toISOString(),
|
||||
toDate: new Date().toISOString(),
|
||||
recruiterId: activeId,
|
||||
top: 500,
|
||||
})
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(interviewsApi.toInterviewView)
|
||||
},
|
||||
enabled: Boolean(activeId),
|
||||
})
|
||||
|
||||
const heatmap = useMemo(() => {
|
||||
|
|
@ -135,7 +189,7 @@ export default function RecruiterHub() {
|
|||
}, [trendQuery.data])
|
||||
|
||||
const pipelineData = useMemo(() => {
|
||||
const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED')
|
||||
const rows = analyticsApi.toBoardStageRows(funnelQuery.data ?? [])
|
||||
return {
|
||||
labels: rows.map((p) => p.stage),
|
||||
data: rows.map((p) => p.count),
|
||||
|
|
@ -144,7 +198,7 @@ export default function RecruiterHub() {
|
|||
}, [funnelQuery.data])
|
||||
|
||||
const board = useMemo(
|
||||
() => [...recruiters].sort((a, b) => (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8),
|
||||
() => [...recruiters].sort((a, b) => (b.completed ?? 0) - (a.completed ?? 0) || (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8),
|
||||
[recruiters],
|
||||
)
|
||||
|
||||
|
|
@ -186,12 +240,27 @@ export default function RecruiterHub() {
|
|||
const k = kpisQuery.data
|
||||
const name = selected.name || 'Recruiter'
|
||||
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 (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Recruiter Hub"
|
||||
sub="Per-recruiter performance, scoped server-side"
|
||||
sub="Per-recruiter hiring progress and assigned tasks"
|
||||
actions={
|
||||
<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>)}
|
||||
|
|
@ -207,7 +276,15 @@ export default function RecruiterHub() {
|
|||
<div style={{ opacity: 0.85 }}>
|
||||
{selected.open_reqs ?? 0} open req{(selected.open_reqs ?? 0) === 1 ? '' : 's'}
|
||||
{' · '}
|
||||
{selected.completed ?? 0} completed
|
||||
{' · '}
|
||||
{selected.hires ?? 0} hire{(selected.hires ?? 0) === 1 ? '' : 's'}
|
||||
{canViewTasks && !tasksLoading ? (
|
||||
<>
|
||||
{' · '}
|
||||
{openTasks.length} open task{openTasks.length === 1 ? '' : 's'}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
|
|
@ -222,6 +299,14 @@ export default function RecruiterHub() {
|
|||
</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 12 }}>Offer acceptance</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>
|
||||
|
||||
|
|
@ -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="Candidates" value={loading ? '—' : (k?.total_candidates ?? 0)} icon="users" tone="i-indigo" foot="in their pipeline" />
|
||||
</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="card">
|
||||
|
|
@ -268,7 +373,7 @@ export default function RecruiterHub() {
|
|||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Interview Load</h3>
|
||||
<span className="ch-sub">Team-wide, last {WEEKS} weeks</span>
|
||||
<span className="ch-sub">This recruiter, last {WEEKS} weeks</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
|
|
@ -307,7 +412,7 @@ export default function RecruiterHub() {
|
|||
More
|
||||
</div>
|
||||
<p className="text-muted" style={{ marginTop: 10, fontSize: 12 }}>
|
||||
<Icon name="info" /> Interviews carry no recruiter, so this counts the whole team.
|
||||
<Icon name="info" /> Counted from interviews on this recruiter’s jobs.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -318,12 +423,12 @@ export default function RecruiterHub() {
|
|||
<div className="grid g-2">
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Top performers by hires</span></div>
|
||||
<div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Ranked by completed requisitions</span></div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{board.length === 0 ? (
|
||||
<EmptyState icon="users" title="No hires recorded yet">
|
||||
The board fills in as applications reach the hired stage.
|
||||
<EmptyState icon="users" title="No completed requisitions yet">
|
||||
Mark a job Completed when hiring finishes to rank recruiters here.
|
||||
</EmptyState>
|
||||
) : (
|
||||
board.map((rec, i) => {
|
||||
|
|
@ -349,8 +454,8 @@ export default function RecruiterHub() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
<div className="fw-600">{rec.hires ?? 0}</div>
|
||||
<div className="lr-sub">hires</div>
|
||||
<div className="fw-600">{rec.completed ?? 0}</div>
|
||||
<div className="lr-sub">completed</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -383,3 +488,95 @@ export default function RecruiterHub() {
|
|||
</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
|
||||
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 = [
|
||||
{ key: 'PENDING', label: 'Shortlist' },
|
||||
{ key: 'CLOSED', label: 'Shortlist' },
|
||||
{ key: 'SCREENING', label: 'Screened' },
|
||||
{ key: 'PROCESS', label: 'Screened' },
|
||||
{ key: 'ONHOLD', label: 'Screened' },
|
||||
{ key: 'ASSESSMENT', label: 'Assessed' },
|
||||
{ key: 'INTERVIEW', label: 'Interviewed' },
|
||||
{ key: 'OFFER', label: 'Offered' },
|
||||
{ key: 'APPROVED', label: 'Hired' },
|
||||
{ key: 'APPROVED', label: 'Approved' },
|
||||
{ key: 'HIRED', label: 'Hired' },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@
|
|||
The layout, the toolbar, the card and the 8-tab profile modal are the
|
||||
originals, unchanged. Only the data source moved.
|
||||
|
||||
The endpoint returns name, email, experience, application_status and the
|
||||
suggested job title. It has no aiScore, skills, currentCompany, source or
|
||||
department — the agent writes a verdict and prose, not a score, and no
|
||||
résumé-derived skills are persisted. Each record is therefore OVERLAID on a
|
||||
seed candidate: real values win, seed fills the rest, so the card renders
|
||||
exactly as it always did.
|
||||
The endpoint returns name, email, experience, application_status, suggested
|
||||
job titles and the attached job_posts (with department). It has no skills or
|
||||
currentCompany on list rows — those still come from the seed overlay. Each
|
||||
record is therefore OVERLAID on a seed candidate: real values win, seed fills
|
||||
the rest, so the card renders exactly as it always did.
|
||||
|
||||
Clicking a card opens CandidateProfile in place. It used to deep-link into
|
||||
/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. */
|
||||
const PAGE_SIZE_MAX = 100
|
||||
|
||||
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', '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',
|
||||
}
|
||||
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||
|
||||
/** "6 years" -> 6. The column is free text, so anything unparseable defers to seed. */
|
||||
function years(value) {
|
||||
|
|
@ -69,6 +58,25 @@ function years(value) {
|
|||
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.
|
||||
*
|
||||
|
|
@ -82,8 +90,9 @@ function merge(row, template) {
|
|||
const title = (row.job_posts || []).map((j) => j.title).find(Boolean)
|
||||
|| row.job_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 departments = departmentsOf(row)
|
||||
|
||||
return {
|
||||
...template,
|
||||
|
|
@ -97,6 +106,10 @@ function merge(row, template) {
|
|||
status: stage,
|
||||
currentTitle: title || template.currentTitle,
|
||||
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.
|
||||
source: row.source || template.source,
|
||||
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
|
||||
|
|
@ -185,7 +198,7 @@ export default function TalentPool() {
|
|||
const list = useMemo(
|
||||
() =>
|
||||
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
|
||||
return true
|
||||
}),
|
||||
|
|
@ -235,7 +248,7 @@ export default function TalentPool() {
|
|||
</div>
|
||||
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
||||
<option value="">All Departments</option>
|
||||
{departments.map((d) => <option key={d}>{d}</option>)}
|
||||
{departments.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
<PageSizeField
|
||||
value={pageSize}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
============================================================ */
|
||||
|
||||
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 Modal from '../ui/Modal'
|
||||
|
|
@ -54,10 +54,12 @@ export default function Tasks() {
|
|||
const { toast } = useToast()
|
||||
const { can, user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const canCreate = can('tasks.create') && CREATOR_ROLES.includes(user?.role_name)
|
||||
const canEdit = can('tasks.edit')
|
||||
const assigneeFilter = searchParams.get('assignee') || ''
|
||||
|
||||
const tasksQuery = useQuery({ queryKey: qk.tasks.list(), queryFn: fetchTasks })
|
||||
const assigneesQuery = useQuery({ queryKey: qk.tasks.assignees(), queryFn: fetchAssignees })
|
||||
|
|
@ -73,17 +75,30 @@ export default function Tasks() {
|
|||
const now = new Date()
|
||||
const isOverdue = (t) => !t.done && t.due && t.due < now
|
||||
|
||||
const list = useMemo(() => {
|
||||
if (filter === 'Open') return tasks.filter((t) => !t.done)
|
||||
if (filter === 'Completed') return tasks.filter((t) => t.done)
|
||||
if (filter === 'Overdue') return tasks.filter(isOverdue)
|
||||
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 scoped = useMemo(() => {
|
||||
if (!assigneeFilter) return tasks
|
||||
return tasks.filter((t) => String(t.assigneeId) === String(assigneeFilter))
|
||||
}, [tasks, assigneeFilter])
|
||||
|
||||
const openCount = tasks.filter((t) => !t.done).length
|
||||
const overdueCount = tasks.filter(isOverdue).length
|
||||
const assigneeLabel = useMemo(() => {
|
||||
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
|
||||
// 403/422 must snap it back rather than lie.
|
||||
|
|
@ -158,7 +173,11 @@ export default function Tasks() {
|
|||
<div className="page">
|
||||
<PageHeader
|
||||
title="Tasks"
|
||||
sub={`${openCount} open · ${overdueCount} overdue`}
|
||||
sub={
|
||||
assigneeFilter
|
||||
? `${openCount} open · ${overdueCount} overdue · ${assigneeLabel || 'this recruiter'}`
|
||||
: `${openCount} open · ${overdueCount} overdue`
|
||||
}
|
||||
actions={
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
|
|
@ -181,6 +200,17 @@ export default function Tasks() {
|
|||
</button>
|
||||
))}
|
||||
</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 className="card-body">
|
||||
<div className="list-tight">
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@
|
|||
|
||||
--stage-1: #0e7490; --stage-2: #5b60e8; --stage-3: #8a5a00; --stage-4: #004d43;
|
||||
--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);
|
||||
/* 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-5: #25e9a5; --stage-6: #a8e063; --stage-7: #ff7a8a;
|
||||
--stage-8: #5ee0b5; --stage-9: #ffb020;
|
||||
|
||||
--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");
|
||||
|
|
@ -993,13 +995,42 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
||||
/* Inbox sidebar only: fit the list instead of scrolling sideways.
|
||||
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 .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 {
|
||||
min-width: 0; max-width: 100%;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
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 .pagination {
|
||||
|
|
@ -1050,7 +1081,8 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
11px copy keeps its contrast in both modes. */
|
||||
.source-chip {
|
||||
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);
|
||||
color: var(--text-2);
|
||||
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-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.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}
|
||||
className={onRowClick ? 'row-click' : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
onClick={onRowClick ? (e) => {
|
||||
if (e.target.closest('button, a, select, input, textarea, label, .row-actions')) return
|
||||
onRowClick(row)
|
||||
} : undefined}
|
||||
onKeyDown={onRowClick ? (e) => {
|
||||
if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row)
|
||||
} : undefined}
|
||||
|
|
|
|||
Loading…
Reference in New Issue