Merge pull request 'Connect_Talent_x_Recruiter' (#34) from Connect_Talent_x_Recruiter into main
Deploy to S3 / deploy (push) Successful in 33s Details

Reviewed-on: #34
pull/35/head
ahmed.mujtaba 2026-08-31 07:12:21 +00:00
commit cd2dd5a5c0
68 changed files with 2822 additions and 1260 deletions

View File

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

View File

@ -20,11 +20,12 @@ def serialize_source_count(source,count,source_id=None,spend=0.0) -> dict:
}
def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire) -> dict:
def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire,completed=0) -> dict:
return {
"id": str(user_id) if user_id else None,
"name": name,
"hires": int(hires or 0),
"completed": int(completed or 0),
"open_reqs": int(open_reqs or 0),
"avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None,
}

View File

@ -1,7 +1,5 @@
import uuid
from datetime import datetime,timedelta,timezone
from sqlalchemy import and_,func,or_,select
from sqlalchemy.ext.asyncio import AsyncSession
from analytics.serializers import (
@ -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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Optional
from fastapi import HTTPException
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, func, or_
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@ -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.

View File

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

View File

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

View File

@ -83,13 +83,46 @@ class HiringCosts(SQLModel, table=True):
return await cls.get_by_id(session, row.id)
@classmethod
async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None):
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
if not department and not recruiter_id:
return statement
from job.job_post.models import JobPosts
statement = statement.outerjoin(JobPosts, cls.job_post_id == JobPosts.id)
if department:
statement = statement.where(JobPosts.department == department)
rid = cls._as_uuid(recruiter_id)
if rid is not None:
statement = statement.where(JobPosts.current_recruiter_id == rid)
return statement
@classmethod
async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None,
department=None, recruiter_id=None):
statement = select(func.coalesce(func.sum(cls.amount), 0.0))
if from_date is not None:
statement = statement.where(cls.incurred_at >= from_date)
if to_date is not None:
statement = statement.where(cls.incurred_at < to_date)
statement = cls.scoped_to_job(statement, department, recruiter_id)
result = await session.execute(statement)
return float(result.scalar_one() or 0.0)
@classmethod
async def sum_by_source_channel(
cls, session: AsyncSession, *, from_date=None, to_date=None,
department=None, recruiter_id=None,
):
statement = select(
cls.source_channel_id,
func.coalesce(func.sum(cls.amount), 0.0),
).where(cls.source_channel_id.is_not(None))
if from_date is not None:
statement = statement.where(cls.incurred_at >= from_date)
if to_date is not None:
statement = statement.where(cls.incurred_at < to_date)
statement = cls.scoped_to_job(statement, department, recruiter_id)
statement = statement.group_by(cls.source_channel_id)
result = await session.execute(statement)
return {channel_id: float(total or 0.0) for channel_id, total in result.all()}
import users.models as _users_models # noqa: E402, F401

View File

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

View File

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

View File

@ -24,7 +24,7 @@ class Interview:
async def _serialize(self,row):
return serialize_interview(row,job_title=await self._job_title_for(row))
async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,top=None,skip=0):
async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,recruiter_id=None,top=None,skip=0):
if interview_id:
row=await Interviews.get_interview_by_id(self.session,interview_id)
if not row:
@ -33,18 +33,19 @@ class Interview:
if inbox_id is not None:
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
return [serialize_interview(r) for r in rows]
if from_date is not None or to_date is not None or status is not None or top is not None:
if from_date is not None or to_date is not None or status is not None or recruiter_id or top is not None:
return await self.get_interviews_range(
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip,
from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip,
)
raise HTTPException(status_code=400,detail="interview_id or inbox_id is required")
async def get_interviews_range(self,from_date=None,to_date=None,status=None,top=None,skip=0):
async def get_interviews_range(self,from_date=None,to_date=None,status=None,recruiter_id=None,top=None,skip=0):
rows,total=await Interviews.get_interviews_in_range(
self.session,
from_date=from_date,
to_date=to_date,
status=status,
recruiter_id=recruiter_id,
top=top,
skip=skip,
)
@ -52,6 +53,7 @@ class Interview:
return [serialize_interview(r,job_title=titles.get(r.inbox_id)) for r in rows],total
async def create_interview(self,payload,current_user=None):
from inbox.models import Inbox
fields={
"interview_date":payload.get("interview_date"),
"interview_time":payload.get("interview_time"),
@ -59,6 +61,13 @@ class Interview:
"interview_status":payload.get("interview_status") or "",
"inbox_id":payload.get("inbox_id"),
}
inbox=await Inbox.get_inbox_with_message(self.session,payload.get("inbox_id"))
if inbox:
if inbox.user_id:
fields["user_id"]=inbox.user_id
msg=inbox.messages
if msg and msg.assigned_job_post_id:
fields["job_post_id"]=msg.assigned_job_post_id
fields={k:v for k,v in fields.items() if v is not None}
row=await Interviews.insert_interview(self.session,fields)
when=row.interview_date or row.interview_time

View File

@ -0,0 +1,47 @@
from enum import Enum
class RequisitionStatus(str, Enum):
"""Hiring lifecycle on job_posts.requisition_status.
Distinct from job_posts.status, which is Buffer publish state
(draft/scheduled/published/failed). Values are the wire form the Jobs
screen already PATCHes; labels are what the dropdown renders.
"""
OPEN = "open"
ON_HOLD = "on_hold"
CLOSED = "closed"
COMPLETED = "completed"
@property
def label(self) -> str:
return _LABELS[self]
@classmethod
def parse(cls, value):
"""Accept the stored value or the UI label. None if neither matches."""
raw = (value or "").strip()
if not raw:
return None
lowered = raw.lower().replace(" ", "_")
for member in cls:
if raw == member.value or lowered == member.value or raw == member.label:
return member
return None
@classmethod
def values(cls) -> tuple[str, ...]:
return tuple(m.value for m in cls)
@classmethod
def as_list(cls) -> list[dict]:
return [{"value": m.value, "label": m.label} for m in cls]
_LABELS = {
RequisitionStatus.OPEN: "Open",
RequisitionStatus.ON_HOLD: "On Hold",
RequisitionStatus.CLOSED: "Closed",
RequisitionStatus.COMPLETED: "Completed",
}

View File

@ -20,8 +20,8 @@ BRAND_DARK = "0B3D2E" # header/banner green, matches the app chrome
BRAND_STRIPE = "EFF7F2" # zebra row tint
BORDER_TINT = "CBDCD2"
STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold"}
STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700"}
STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold", "completed": "Completed"}
STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700", "completed": "0F6E56"}
# (header, column width)
COLUMNS = [
@ -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):

View File

@ -2,10 +2,12 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, JSON, func, or_
from sqlalchemy import DateTime, JSON, Index, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
from job.job_post.enums import RequisitionStatus
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
from users.models import Users
@ -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.

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,21 @@
-- 016_job_post_status_history.sql
-- Audit log of job_posts.requisition_status changes (who, from, to, when).
-- The Jobs History tab merges this with job_assignments. Applied at startup
-- by alembic_setup.run_manual_sql(). Needed because prod boots with
-- DB_AUTOGENERATE=false.
CREATE TABLE IF NOT EXISTS app.job_post_status_history (
id UUID PRIMARY KEY,
job_post_id UUID NOT NULL REFERENCES app.job_posts(id),
from_status VARCHAR,
to_status VARCHAR NOT NULL,
changed_by UUID REFERENCES app.users(id),
actor_kind VARCHAR NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_job_post_status_history_job_post_id
ON app.job_post_status_history (job_post_id);
CREATE INDEX IF NOT EXISTS ix_job_post_status_history_job_created
ON app.job_post_status_history (job_post_id, created_at);

View File

@ -115,6 +115,38 @@ class Offers(SQLModel, table=True):
result = await session.execute(statement)
return result.scalar_one()
@classmethod
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
if not department and not recruiter_id:
return statement
from job.job_post.models import JobPosts
statement = statement.outerjoin(JobPosts, cls.job_post_id == JobPosts.id)
if department:
statement = statement.where(JobPosts.department == department)
rid = cls._as_uuid(recruiter_id)
if rid is not None:
statement = statement.where(JobPosts.current_recruiter_id == rid)
return statement
@classmethod
async def count_in_window(
cls, session: AsyncSession, statuses=None, from_date=None, to_date=None,
department=None, recruiter_id=None, *, exclude_draft=False,
):
statement = select(func.count()).select_from(cls)
if exclude_draft:
statement = statement.where(cls.status != "draft")
elif statuses:
statement = statement.where(cls.status.in_(statuses))
stamp = func.coalesce(cls.sent_at, cls.responded_at, cls.created_at)
if from_date is not None:
statement = statement.where(stamp >= from_date)
if to_date is not None:
statement = statement.where(stamp < to_date)
statement = cls.scoped_to_job(statement, department, recruiter_id)
result = await session.execute(statement)
return int(result.scalar_one() or 0)
class OfferStatusHistory(SQLModel, table=True):
__tablename__ = "offer_status_history"

View File

@ -184,6 +184,7 @@ async def _build_recruiter_performance(session, f):
row["avg_time_to_hire"] = _round(row.get("avg_time_to_hire"))
columns = [
{"key": "name", "label": "Recruiter"},
{"key": "completed", "label": "Completed Requisitions"},
{"key": "hires", "label": "Hires"},
{"key": "open_reqs", "label": "Open Requisitions"},
{"key": "avg_time_to_hire", "label": "Avg Time to Hire (days)"},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,12 +35,13 @@ export const INTERVIEW_TYPES = [
]
/** Range read. `top` is always sent so the route takes the range branch. */
export function listRange({ fromDate, toDate, status, top = 200, skip } = {}) {
export function listRange({ fromDate, toDate, status, recruiterId, top = 200, skip } = {}) {
return request('/interview/fetch', {
params: {
from_date: fromDate,
to_date: toDate,
status,
recruiter_id: recruiterId,
top,
skip,
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&apos;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"

View File

@ -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, 15 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="Couldnt 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="Couldnt load this application">
<EmptyState icon="alert" title="Couldnt 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>

View File

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

View File

@ -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 recruiters 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 recruiters tasks.</EmptyState>
) : query.isError ? (
<EmptyState icon="alert" title="Couldnt 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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -248,7 +248,10 @@ export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE,
key={row.id ?? i}
className={onRowClick ? 'row-click' : undefined}
tabIndex={onRowClick ? 0 : undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
onClick={onRowClick ? (e) => {
if (e.target.closest('button, a, select, input, textarea, label, .row-actions')) return
onRowClick(row)
} : undefined}
onKeyDown={onRowClick ? (e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row)
} : undefined}