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/kpis/fetch` | The KPI cards, each with a prior-period comparison |
| GET | `/analytics/hiring-trend/fetch?months=7` | Applications vs hires by month | | GET | `/analytics/hiring-trend/fetch?months=7` | Applications vs hires by month |
| GET | `/analytics/funnel/fetch` | Candidate count per stage | | GET | `/analytics/funnel/fetch` | Candidate count per stage (inbox + manual-upload, same population as the pipeline board) |
| GET | `/analytics/recruiter-performance/fetch?top=5` | Per recruiter: hires, open reqs, avg time-to-hire | | GET | `/analytics/recruiter-performance/fetch?top=5` | Per recruiter: hires, open reqs, avg time-to-hire |
| GET | `/analytics/source-performance/fetch` | Applications per source channel | | GET | `/analytics/source-performance/fetch` | Applications per source channel |

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 { return {
"id": str(user_id) if user_id else None, "id": str(user_id) if user_id else None,
"name": name, "name": name,
"hires": int(hires or 0), "hires": int(hires or 0),
"completed": int(completed or 0),
"open_reqs": int(open_reqs or 0), "open_reqs": int(open_reqs or 0),
"avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None, "avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None,
} }

View File

@ -1,7 +1,5 @@
import uuid
from datetime import datetime,timedelta,timezone from datetime import datetime,timedelta,timezone
from sqlalchemy import and_,func,or_,select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from analytics.serializers import ( from analytics.serializers import (
@ -12,24 +10,16 @@ from analytics.serializers import (
from inbox.enums import Candidate_application_Status from inbox.enums import Candidate_application_Status
from inbox.models import Inbox,Inbox_Messages,SourceChannels from inbox.models import Inbox,Inbox_Messages,SourceChannels
from job.assignment.models import JobAssignments from job.assignment.models import JobAssignments
from job.candidate.models import ApplicationStageTransitions,Interviews from job.candidate.models import ApplicationStageTransitions,Interviews,Manual_UPLOAD_CANDIDATE
from job.cost.models import HiringCosts from job.cost.models import HiringCosts
from job.job_post.enums import RequisitionStatus
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
from offer.models import Offers from offer.models import Offers
from org_settings.models import OrgSettings from org_settings.models import OrgSettings
from role.models import EnumRoles,Roles from role.models import EnumRoles
from users.models import Users from users.models import Users
def _as_uuid(value):
if value in (None,""):
return None
try:
return uuid.UUID(str(value))
except (TypeError,ValueError):
return None
def _month_start(dt: datetime) -> datetime: def _month_start(dt: datetime) -> datetime:
return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) return datetime(dt.year,dt.month,1,tzinfo=timezone.utc)
@ -75,220 +65,29 @@ def _month_key(dt):
return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) return datetime(dt.year,dt.month,1,tzinfo=timezone.utc)
def _days_expr(end_col,start_col):
return func.extract("epoch",end_col-start_col)/86400.0
class Analytics: class Analytics:
def __init__(self,session:AsyncSession): def __init__(self,session:AsyncSession):
self.session=session self.session=session
async def _count_jobs(self,status,from_date=None,to_date=None,department=None,recruiter_id=None,*,closed_in_window=False):
statement=select(func.count()).select_from(JobPosts).where(JobPosts.is_deleted==False) # noqa: E712
if status:
statement=statement.where(JobPosts.requisition_status==status)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
if closed_in_window:
if from_date is not None:
statement=statement.where(JobPosts.closed_at>=from_date)
if to_date is not None:
statement=statement.where(JobPosts.closed_at<to_date)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_open_snapshot(self,as_of,department=None,recruiter_id=None):
"""Jobs that existed and were still open at `as_of` (best-effort)."""
statement=select(func.count()).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.created_at<as_of,
or_(JobPosts.closed_at.is_(None),JobPosts.closed_at>=as_of),
JobPosts.requisition_status=="open",
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_candidates(self,from_date=None,to_date=None,department=None,recruiter_id=None):
statement=(
select(func.count())
.select_from(Inbox)
.join(Users,Inbox.user_id==Users.id)
.join(Roles,Users.role_id==Roles.id)
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
)
if from_date is not None:
statement=statement.where(Inbox.created_at>=from_date)
if to_date is not None:
statement=statement.where(Inbox.created_at<to_date)
# Best-effort department/recruiter via linked message → job post
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_offers(self,statuses,from_date=None,to_date=None,department=None,recruiter_id=None,*,exclude_draft=False):
statement=select(func.count()).select_from(Offers)
if exclude_draft:
statement=statement.where(Offers.status!="draft")
elif statuses:
statement=statement.where(Offers.status.in_(statuses))
stamp=func.coalesce(Offers.sent_at,Offers.responded_at,Offers.created_at)
if from_date is not None:
statement=statement.where(stamp>=from_date)
if to_date is not None:
statement=statement.where(stamp<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Offers.job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_hires(self,from_date=None,to_date=None,department=None,recruiter_id=None): async def _count_hires(self,from_date=None,to_date=None,department=None,recruiter_id=None):
# Prefer HIRED transitions in window; fall back path uses inbox_messages status. count=await ApplicationStageTransitions.count_hires(
hired=ApplicationStageTransitions self.session,from_date=from_date,to_date=to_date,
statement=select(func.count()).select_from(hired).where(hired.to_stage==Candidate_application_Status.HIRED.value) department=department,recruiter_id=recruiter_id,
if from_date is not None:
statement=statement.where(hired.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hired.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hired.inbox_id==Inbox.id)
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
) )
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
result=await self.session.execute(statement)
count=int(result.scalar_one() or 0)
if count: if count:
return count return count
# Fallback: messages currently HIRED, windowed via inbox.created_at return await Inbox_Messages.count_hired(
msg=( self.session,from_date=from_date,to_date=to_date,
select(func.count()) department=department,recruiter_id=recruiter_id,
.select_from(Inbox_Messages)
.join(Inbox,Inbox.message_id==Inbox_Messages.id)
.where(Inbox_Messages.application_status==Candidate_application_Status.HIRED)
) )
if from_date is not None:
msg=msg.where(Inbox.created_at>=from_date)
if to_date is not None:
msg=msg.where(Inbox.created_at<to_date)
if department or recruiter_id:
msg=msg.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
msg=msg.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
msg=msg.where(or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid))
result=await self.session.execute(msg)
return int(result.scalar_one() or 0)
async def _avg_time_to_hire(self,from_date=None,to_date=None,department=None,recruiter_id=None):
entry=ApplicationStageTransitions.__table__.alias("entry")
hire=ApplicationStageTransitions.__table__.alias("hire")
days=_days_expr(hire.c.valid_from,entry.c.valid_from)
statement=(
select(func.avg(days))
.select_from(
hire.join(
entry,
and_(
hire.c.inbox_id==entry.c.inbox_id,
entry.c.from_stage.is_(None),
),
)
)
.where(hire.c.to_stage==Candidate_application_Status.HIRED.value)
)
if from_date is not None:
statement=statement.where(hire.c.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hire.c.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hire.c.inbox_id==Inbox.id)
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
result=await self.session.execute(statement)
value=result.scalar_one()
return float(value) if value is not None else None
async def _avg_time_to_fill(self,from_date=None,to_date=None,department=None,recruiter_id=None):
days=_days_expr(JobPosts.closed_at,JobPosts.created_at)
statement=select(func.avg(days)).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.requisition_status=="closed",
JobPosts.closed_at.is_not(None),
)
if from_date is not None:
statement=statement.where(JobPosts.closed_at>=from_date)
if to_date is not None:
statement=statement.where(JobPosts.closed_at<to_date)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
value=result.scalar_one()
return float(value) if value is not None else None
async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None): async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None):
if not hires: if not hires:
return None return None
statement=select(func.coalesce(func.sum(HiringCosts.amount),0.0)) total=await HiringCosts.sum_amount(
if from_date is not None: self.session,from_date=from_date,to_date=to_date,
statement=statement.where(HiringCosts.incurred_at>=from_date) department=department,recruiter_id=recruiter_id,
if to_date is not None: )
statement=statement.where(HiringCosts.incurred_at<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,HiringCosts.job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
total=float(result.scalar_one() or 0.0)
return total/hires return total/hires
async def get_kpis(self,from_date=None,to_date=None,department=None,recruiter_id=None): async def get_kpis(self,from_date=None,to_date=None,department=None,recruiter_id=None):
@ -297,58 +96,72 @@ class Analytics:
today_start=datetime(now.year,now.month,now.day,tzinfo=timezone.utc) today_start=datetime(now.year,now.month,now.day,tzinfo=timezone.utc)
tomorrow=today_start+timedelta(days=1) tomorrow=today_start+timedelta(days=1)
open_jobs=await self._count_jobs("open",department=department,recruiter_id=recruiter_id) open_jobs=await JobPosts.count_requisitions(
open_jobs_prior=await self._count_open_snapshot(window_from,department=department,recruiter_id=recruiter_id) self.session,status="open",department=department,recruiter_id=recruiter_id,
closed_jobs=await self._count_jobs(
"closed",window_from,window_to,department,recruiter_id,closed_in_window=True
) )
closed_jobs_prior=await self._count_jobs( open_jobs_prior=await JobPosts.count_open_snapshot(
"closed",prior_from,prior_to,department,recruiter_id,closed_in_window=True self.session,window_from,department=department,recruiter_id=recruiter_id,
) )
total_candidates=await self._count_candidates(window_from,window_to,department,recruiter_id) closed_jobs=await JobPosts.count_requisitions(
total_candidates_prior=await self._count_candidates(prior_from,prior_to,department,recruiter_id) self.session,status="closed",department=department,recruiter_id=recruiter_id,
from_date=window_from,to_date=window_to,closed_in_window=True,
interviews_today_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_date>=today_start,
Interviews.interview_date<tomorrow,
) )
interviews_today=int((await self.session.execute(interviews_today_q)).scalar_one() or 0) closed_jobs_prior=await JobPosts.count_requisitions(
self.session,status="closed",department=department,recruiter_id=recruiter_id,
upcoming_q=select(func.count()).select_from(Interviews).where( from_date=prior_from,to_date=prior_to,closed_in_window=True,
Interviews.interview_status.ilike("scheduled"),
Interviews.interview_date>=now,
) )
interviews_upcoming=int((await self.session.execute(upcoming_q)).scalar_one() or 0)
next_q=select(func.min(func.coalesce(Interviews.interview_time,Interviews.interview_date))).where( total_candidates=await Inbox.count_in_window(
Interviews.interview_status.ilike("scheduled"), self.session,window_from,window_to,department,recruiter_id,
Interviews.interview_date>=now, )
total_candidates_prior=await Inbox.count_in_window(
self.session,prior_from,prior_to,department,recruiter_id,
)
interviews_today=await Interviews.count_between(
self.session,today_start,tomorrow,recruiter_id=recruiter_id,
)
interviews_upcoming=await Interviews.count_upcoming(
self.session,now,recruiter_id=recruiter_id,
)
next_at=await Interviews.next_scheduled_at(
self.session,now,recruiter_id=recruiter_id,
) )
next_at=(await self.session.execute(next_q)).scalar_one()
next_interview_at=next_at.isoformat() if next_at else None next_interview_at=next_at.isoformat() if next_at else None
offers_accepted=await self._count_offers(["accepted"],window_from,window_to,department,recruiter_id) offers_accepted=await Offers.count_in_window(
offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id) self.session,["accepted"],window_from,window_to,department,recruiter_id,
offers_sent=await self._count_offers(
["sent","negotiating","accepted","declined","expired"],
window_from,window_to,department,recruiter_id,exclude_draft=True,
) )
offers_sent_prior=await self._count_offers( offers_accepted_prior=await Offers.count_in_window(
["sent","negotiating","accepted","declined","expired"], self.session,["accepted"],prior_from,prior_to,department,recruiter_id,
prior_from,prior_to,department,recruiter_id,exclude_draft=True, )
offers_sent=await Offers.count_in_window(
self.session,None,window_from,window_to,department,recruiter_id,exclude_draft=True,
)
offers_sent_prior=await Offers.count_in_window(
self.session,None,prior_from,prior_to,department,recruiter_id,exclude_draft=True,
) )
hires=await self._count_hires(window_from,window_to,department,recruiter_id) hires=await self._count_hires(window_from,window_to,department,recruiter_id)
hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id) hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id)
time_to_hire=await self._avg_time_to_hire(window_from,window_to,department,recruiter_id) time_to_hire=await ApplicationStageTransitions.avg_time_to_hire(
time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id) self.session,window_from,window_to,department,recruiter_id,
time_to_fill=await self._avg_time_to_fill(window_from,window_to,department,recruiter_id) )
time_to_fill_prior=await self._avg_time_to_fill(prior_from,prior_to,department,recruiter_id) time_to_hire_prior=await ApplicationStageTransitions.avg_time_to_hire(
self.session,prior_from,prior_to,department,recruiter_id,
)
time_to_fill=await JobPosts.avg_time_to_fill(
self.session,window_from,window_to,department,recruiter_id,
)
time_to_fill_prior=await JobPosts.avg_time_to_fill(
self.session,prior_from,prior_to,department,recruiter_id,
)
cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id) cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id)
cost_per_hire_prior=await self._cost_per_hire(hires_prior,prior_from,prior_to,department,recruiter_id) cost_per_hire_prior=await self._cost_per_hire(
hires_prior,prior_from,prior_to,department,recruiter_id,
)
# REQ-ANL-08: the time-to-hire baseline is an org setting with provenance # REQ-ANL-08: the time-to-hire baseline is an org setting with provenance
# ({"days": N, "source": "..."}), never a constant — OPEN-12 flags the BRD's # ({"days": N, "source": "..."}), never a constant — OPEN-12 flags the BRD's
@ -397,28 +210,27 @@ class Analytics:
} }
async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None): async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None):
statement=select( # Same two sources the pipeline board counts: inbox applications on an
Inbox_Messages.application_status, # assigned job, plus manual-upload candidates. Counting only
func.count().label("count"), # inbox_messages left Add Candidate rows (and anyone dragged to
).select_from(Inbox_Messages) # Interview there) invisible on the dashboard doughnut.
if department or recruiter_id: inbox_counts=await Inbox_Messages.counts_by_application_status(
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) self.session,from_date=from_date,to_date=to_date,
if department: department=department,recruiter_id=recruiter_id,
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
) )
if from_date is not None or to_date is not None: manual_counts=await Manual_UPLOAD_CANDIDATE.counts_by_application_status(
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) self.session,from_date=from_date,to_date=to_date,
if from_date is not None: department=department,recruiter_id=recruiter_id,
statement=statement.where(Inbox.created_at>=from_date) )
if to_date is not None: counts={stage.value:0 for stage in Candidate_application_Status}
statement=statement.where(Inbox.created_at<to_date) pending=Candidate_application_Status.PENDING.value
statement=statement.group_by(Inbox_Messages.application_status) for src in (inbox_counts,manual_counts):
result=await self.session.execute(statement) for key,n in src.items():
counts={str(row[0].value if hasattr(row[0],"value") else row[0]): int(row[1] or 0) for row in result.all()} n=int(n or 0)
if key in counts:
counts[key]+=n
else:
counts[pending]+=n
return [ return [
serialize_stage_count(stage.value,counts.get(stage.value,0)) serialize_stage_count(stage.value,counts.get(stage.value,0))
for stage in Candidate_application_Status for stage in Candidate_application_Status
@ -428,65 +240,20 @@ class Analytics:
months=max(1,int(months or 7)) months=max(1,int(months or 7))
now=datetime.now(timezone.utc) now=datetime.now(timezone.utc)
start=_month_start(now) start=_month_start(now)
# Walk back (months-1) months
for _ in range(months-1): for _ in range(months-1):
start=_month_start(start-timedelta(days=1)) start=_month_start(start-timedelta(days=1))
month_bucket=func.date_trunc("month",Inbox.created_at)
apps_q=(
select(month_bucket.label("month"),func.count().label("count"))
.select_from(Inbox)
.where(Inbox.created_at>=start)
.group_by(month_bucket)
.order_by(month_bucket)
)
if department or recruiter_id:
apps_q=(
apps_q
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
apps_q=apps_q.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
apps_q=apps_q.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
apps_rows=await self.session.execute(apps_q)
apps_map={} apps_map={}
for month,count in apps_rows.all(): for month,count in await Inbox.counts_by_month(
apps_map[_month_key(month)]=int(count or 0) self.session,start,department=department,recruiter_id=recruiter_id,
):
apps_map[_month_key(month)]=count
hire_bucket=func.date_trunc("month",ApplicationStageTransitions.valid_from)
hires_q=(
select(hire_bucket.label("month"),func.count().label("count"))
.select_from(ApplicationStageTransitions)
.where(
ApplicationStageTransitions.to_stage==Candidate_application_Status.HIRED.value,
ApplicationStageTransitions.valid_from>=start,
)
.group_by(hire_bucket)
.order_by(hire_bucket)
)
if department or recruiter_id:
hires_q=(
hires_q
.outerjoin(Inbox,ApplicationStageTransitions.inbox_id==Inbox.id)
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
hires_q=hires_q.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
hires_q=hires_q.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
hire_rows=await self.session.execute(hires_q)
hire_map={} hire_map={}
for month,count in hire_rows.all(): for month,count in await ApplicationStageTransitions.counts_hires_by_month(
hire_map[_month_key(month)]=int(count or 0) self.session,start,department=department,recruiter_id=recruiter_id,
):
hire_map[_month_key(month)]=count
labels=[] labels=[]
applications=[] applications=[]
@ -500,57 +267,18 @@ class Analytics:
return {"labels": labels,"applications": applications,"hires": hires} return {"labels": labels,"applications": applications,"hires": hires}
async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None): async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None):
statement=( rows=await Inbox_Messages.counts_by_source(
select( self.session,from_date=from_date,to_date=to_date,
SourceChannels.id.label("source_id"), department=department,recruiter_id=recruiter_id,
func.coalesce(SourceChannels.label,"Unknown").label("source"),
func.count().label("count"),
) )
.select_from(Inbox_Messages)
.outerjoin(SourceChannels,Inbox_Messages.source_channel_id==SourceChannels.id)
)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
if from_date is not None or to_date is not None:
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
statement=statement.where(Inbox.created_at>=from_date)
if to_date is not None:
statement=statement.where(Inbox.created_at<to_date)
statement=statement.group_by(SourceChannels.id,SourceChannels.label).order_by(func.count().desc())
result=await self.session.execute(statement)
rows=result.all()
# REQ-ANL-09 cost side: spend explicitly tagged to a source channel in the # REQ-ANL-09 cost side: spend explicitly tagged to a source channel in the
# cost ledger. Untagged spend is deliberately excluded — it belongs to # cost ledger. Untagged spend is deliberately excluded — it belongs to
# cost-per-hire, and folding it into "Unknown" would fabricate a ROI figure. # cost-per-hire, and folding it into "Unknown" would fabricate a ROI figure.
spend_q=select( spend_map=await HiringCosts.sum_by_source_channel(
HiringCosts.source_channel_id, self.session,from_date=from_date,to_date=to_date,
func.coalesce(func.sum(HiringCosts.amount),0.0), department=department,recruiter_id=recruiter_id,
).where(HiringCosts.source_channel_id.is_not(None)) )
if from_date is not None:
spend_q=spend_q.where(HiringCosts.incurred_at>=from_date)
if to_date is not None:
spend_q=spend_q.where(HiringCosts.incurred_at<to_date)
if department or recruiter_id:
spend_q=spend_q.outerjoin(JobPosts,HiringCosts.job_post_id==JobPosts.id)
if department:
spend_q=spend_q.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
spend_q=spend_q.where(JobPosts.current_recruiter_id==rid)
spend_q=spend_q.group_by(HiringCosts.source_channel_id)
spend_map={
channel_id:float(total or 0.0)
for channel_id,total in (await self.session.execute(spend_q)).all()
}
# A channel with tagged spend but zero applications must still get a row: # A channel with tagged spend but zero applications must still get a row:
# spend that produced nothing is the strongest ROI signal this table has, # spend that produced nothing is the strongest ROI signal this table has,
@ -558,9 +286,9 @@ class Analytics:
present={source_id for source_id,_,_ in rows} present={source_id for source_id,_,_ in rows}
missing=[cid for cid in spend_map if cid not in present] missing=[cid for cid in spend_map if cid not in present]
if missing: if missing:
channels_q=select(SourceChannels.id,SourceChannels.label).where(SourceChannels.id.in_(missing))
rows=list(rows)+[ rows=list(rows)+[
(cid,label,0) for cid,label in (await self.session.execute(channels_q)).all() (cid,label,0)
for cid,label in await SourceChannels.labels_by_ids(self.session,missing)
] ]
return [ return [
@ -572,52 +300,31 @@ class Analytics:
async def get_recruiter_performance(self,top=5,from_date=None,to_date=None,department=None,recruiter_id=None): async def get_recruiter_performance(self,top=5,from_date=None,to_date=None,department=None,recruiter_id=None):
top=max(1,int(top or 5)) top=max(1,int(top or 5))
recruiters_q=( recruiters=await Users.list_by_role_name(
select(Users) self.session,EnumRoles.RECRUITER.value,user_id=recruiter_id,
.join(Roles,Users.role_id==Roles.id)
.where(
Roles.role_name==EnumRoles.RECRUITER.value,
Users.is_deleted==False, # noqa: E712
) )
)
rid=_as_uuid(recruiter_id)
if rid is not None:
recruiters_q=recruiters_q.where(Users.id==rid)
recruiters=list((await self.session.execute(recruiters_q)).scalars().all())
rows=[] rows=[]
for user in recruiters: for user in recruiters:
hires_q=select(func.count()).select_from(Inbox_Messages).where( hires=await Inbox_Messages.count_hires_by_recruiter(
Inbox_Messages.recruiter_id==user.id, self.session,user.id,from_date=from_date,to_date=to_date,department=department,
Inbox_Messages.application_status==Candidate_application_Status.HIRED,
) )
if department:
hires_q=hires_q.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id).where(
JobPosts.department==department
)
if from_date is not None or to_date is not None:
hires_q=hires_q.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
hires_q=hires_q.where(Inbox.created_at>=from_date)
if to_date is not None:
hires_q=hires_q.where(Inbox.created_at<to_date)
hires=int((await self.session.execute(hires_q)).scalar_one() or 0)
open_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id) open_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id)
open_posts_q=select(func.count()).select_from(JobPosts).where( open_posts=await JobPosts.count_by_current_recruiter(
JobPosts.current_recruiter_id==user.id, self.session,user.id,status=RequisitionStatus.OPEN.value,department=department,
JobPosts.requisition_status=="open",
JobPosts.is_deleted==False, # noqa: E712
) )
if department:
open_posts_q=open_posts_q.where(JobPosts.department==department)
open_posts=int((await self.session.execute(open_posts_q)).scalar_one() or 0)
open_reqs=max(open_assign,open_posts) open_reqs=max(open_assign,open_posts)
completed=await JobPosts.count_by_current_recruiter(
avg_tth=await self._avg_time_to_hire( self.session,user.id,status=RequisitionStatus.COMPLETED.value,
from_date,to_date,department,recruiter_id=str(user.id) department=department,from_date=from_date,to_date=to_date,
) )
rows.append(serialize_recruiter_row(user.id,user.name,hires,open_reqs,avg_tth)) avg_tth=await ApplicationStageTransitions.avg_time_to_hire(
self.session,from_date=from_date,to_date=to_date,
department=department,recruiter_id=str(user.id),
)
rows.append(serialize_recruiter_row(
user.id,user.name,hires,open_reqs,avg_tth,completed=completed,
))
rows.sort(key=lambda r: r["hires"],reverse=True) rows.sort(key=lambda r: (r["completed"], r["hires"]),reverse=True)
return rows[:top] return rows[:top]

View File

@ -4,9 +4,7 @@ from datetime import timezone
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from assessments.models import Assessments, _now from assessments.models import Assessments, _now
from assessments.serializers import serialize_assessment from assessments.serializers import serialize_assessment
@ -82,12 +80,7 @@ class Assessment:
inbox_by_id = {} inbox_by_id = {}
if inbox_ids: if inbox_ids:
result = await self.session.execute( inbox_by_id = {row.id: row for row in await Inbox.get_by_ids(self.session, inbox_ids)}
select(Inbox)
.options(selectinload(Inbox.messages), selectinload(Inbox.user))
.where(Inbox.id.in_(inbox_ids))
)
inbox_by_id = {row.id: row for row in result.scalars().all()}
for row in inbox_by_id.values(): for row in inbox_by_id.values():
msg = row.messages msg = row.messages
if msg is not None and msg.assigned_job_post_id: if msg is not None and msg.assigned_job_post_id:
@ -95,10 +88,9 @@ class Assessment:
manual_by_id = {} manual_by_id = {}
if manual_ids: if manual_ids:
result = await self.session.execute( manual_by_id = {
select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids)) row.id: row for row in await Manual_UPLOAD_CANDIDATE.get_by_ids(self.session, manual_ids)
) }
manual_by_id = {row.id: row for row in result.scalars().all()}
for row in manual_by_id.values(): for row in manual_by_id.values():
if row.job_post_id: if row.job_post_id:
job_ids.append(row.job_post_id) job_ids.append(row.job_post_id)
@ -106,8 +98,9 @@ class Assessment:
jobs_by_id = {} jobs_by_id = {}
uids = [j for j in set(job_ids) if j] uids = [j for j in set(job_ids) if j]
if uids: if uids:
result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids))) jobs_by_id = {
jobs_by_id = {row.id: row for row in result.scalars().all()} row.id: row for row in await JobPosts.get_by_ids(self.session, uids, active_only=False)
}
return inbox_by_id, manual_by_id, jobs_by_id return inbox_by_id, manual_by_id, jobs_by_id
def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id): def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id):

View File

@ -3,9 +3,7 @@ import uuid
from datetime import timezone from datetime import timezone
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from candidate_forms.models import CandidateForms, _now from candidate_forms.models import CandidateForms, _now
from candidate_forms.plugins import ( from candidate_forms.plugins import (
@ -138,12 +136,7 @@ class CandidateForm:
inbox_by_id = {} inbox_by_id = {}
if inbox_ids: if inbox_ids:
result = await self.session.execute( inbox_by_id = {row.id: row for row in await Inbox.get_by_ids(self.session, inbox_ids)}
select(Inbox)
.options(selectinload(Inbox.messages), selectinload(Inbox.user))
.where(Inbox.id.in_(inbox_ids))
)
inbox_by_id = {row.id: row for row in result.scalars().all()}
for row in inbox_by_id.values(): for row in inbox_by_id.values():
msg = row.messages msg = row.messages
if msg is not None and msg.assigned_job_post_id: if msg is not None and msg.assigned_job_post_id:
@ -151,10 +144,9 @@ class CandidateForm:
manual_by_id = {} manual_by_id = {}
if manual_ids: if manual_ids:
result = await self.session.execute( manual_by_id = {
select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids)) row.id: row for row in await Manual_UPLOAD_CANDIDATE.get_by_ids(self.session, manual_ids)
) }
manual_by_id = {row.id: row for row in result.scalars().all()}
for row in manual_by_id.values(): for row in manual_by_id.values():
if row.job_post_id: if row.job_post_id:
job_ids.append(row.job_post_id) job_ids.append(row.job_post_id)
@ -162,17 +154,13 @@ class CandidateForm:
jobs_by_id = {} jobs_by_id = {}
uids = [j for j in set(job_ids) if j] uids = [j for j in set(job_ids) if j]
if uids: if uids:
result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids))) jobs_by_id = {
jobs_by_id = {row.id: row for row in result.scalars().all()} row.id: row for row in await JobPosts.get_by_ids(self.session, uids, active_only=False)
}
user_ids = {r.interviewer_id for r in rows if r.interviewer_id} user_ids = {r.interviewer_id for r in rows if r.interviewer_id}
user_ids |= {r.created_by for r in rows if r.created_by} user_ids |= {r.created_by for r in rows if r.created_by}
users_by_id = {} users_by_id = await Users.names_by_ids(self.session, user_ids)
if user_ids:
result = await self.session.execute(
select(Users.id, Users.name).where(Users.id.in_(user_ids))
)
users_by_id = {uid: name for uid, name in result.all()}
return inbox_by_id, manual_by_id, jobs_by_id, users_by_id return inbox_by_id, manual_by_id, jobs_by_id, users_by_id
def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id): def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id):
@ -210,8 +198,8 @@ class CandidateForm:
row, row,
candidate_name=name, candidate_name=name,
job_title=title, job_title=title,
interviewer_name=users_by_id.get(row.interviewer_id), interviewer_name=users_by_id.get(str(row.interviewer_id)) if row.interviewer_id else None,
created_by_name=users_by_id.get(row.created_by), created_by_name=users_by_id.get(str(row.created_by)) if row.created_by else None,
) )
) )
return out return out

View File

@ -60,6 +60,7 @@ def clamp_in_resume(key,sentinel):
def _clean_linkedin(value,resume_text): def _clean_linkedin(value,resume_text):
"""Keep a LinkedIn URL only when the CV evidences it. Sentinel / invented → None."""
url=(value or "").strip() url=(value or "").strip()
if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"): if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"):
return None return None
@ -70,7 +71,19 @@ def _clean_linkedin(value,resume_text):
return None return None
if not lowered.startswith("http://") and not lowered.startswith("https://"): if not lowered.startswith("http://") and not lowered.startswith("https://"):
url="https://"+url.lstrip("/") url="https://"+url.lstrip("/")
text=(resume_text or "").strip()
if not text:
return url return url
from linkedin_utils import slug_from_url,slugs_from_text
agent_slug=slug_from_url(url)
if agent_slug:
return url if agent_slug in slugs_from_text(text) else None
if "lnkd.in" in lowered:
from linkedin_utils import profile_url_from_text
evidenced=profile_url_from_text(text)
if evidenced and "lnkd.in" in evidenced.lower():
return evidenced
return None
def _clean_phone(value,resume_text): def _clean_phone(value,resume_text):

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. - Copy the full slug. Never drop a trailing path segment.
- Do not return a company page (linkedin.com/company/...), a search URL, or a URL that is not LinkedIn. - Do not return a company page (linkedin.com/company/...), a search URL, or a URL that is not LinkedIn.
- Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN} - Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN}
- Never guess a slug or construct linkedin.com/in/<name> from the candidate's name. The stored value will be null when this sentinel is returned.
phone (its own key extract this separately; copy EVERY digit): phone (its own key extract this separately; copy EVERY digit):
- Return the candidate's own mobile / phone exactly as written, including country code when present. - Return the candidate's own mobile / phone exactly as written, including country code when present.

View File

@ -290,7 +290,7 @@ async def set_form_processing_state(
): ):
try: try:
service=SheetFormData(session=session) service=SheetFormData(session=session)
data=await service.set_processing_state(record_id,payload.processing_state) data=await service.set_processing_state(record_id,payload.processing_state,current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200}) return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -416,6 +416,13 @@ class SheetImportRun(SQLModel, table=True):
) )
return result.scalars().first() return result.scalars().first()
@classmethod
async def get_latest(cls, session: AsyncSession):
result = await session.execute(
select(cls).order_by(cls.created_at.desc()).limit(1)
)
return result.scalars().first()
@classmethod @classmethod
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True): async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
row = cls(**fields) row = cls(**fields)

View File

@ -319,11 +319,7 @@ class SheetImport(SheetRead):
row=await SheetImportRun.get_active(session) row=await SheetImportRun.get_active(session)
if row: if row:
return serialize_import_run(row) return serialize_import_run(row)
from sqlmodel import select row=await SheetImportRun.get_latest(session)
result=await session.execute(
select(SheetImportRun).order_by(SheetImportRun.created_at.desc()).limit(1)
)
row=result.scalars().first()
if not row: if not row:
raise HTTPException(status_code=404,detail="No import runs yet") raise HTTPException(status_code=404,detail="No import runs yet")
return serialize_import_run(row) return serialize_import_run(row)
@ -411,7 +407,7 @@ class SheetFormData(Sheet):
await self._promote_to_application(updated) await self._promote_to_application(updated)
return await self.get_form_data_by_id(record_id) return await self.get_form_data_by_id(record_id)
async def set_processing_state(self,record_id,processing_state): async def set_processing_state(self,record_id,processing_state,current_user=None):
allowed=("unread","imported","processed","rejected") allowed=("unread","imported","processed","rejected")
if processing_state not in allowed: if processing_state not in allowed:
raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}") raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}")
@ -423,7 +419,18 @@ class SheetFormData(Sheet):
if processing_state=="processed": if processing_state=="processed":
if not row.job_post_id: if not row.job_post_id:
raise HTTPException(status_code=422,detail="Assign a job post before moving to shortlist") raise HTTPException(status_code=422,detail="Assign a job post before moving to shortlist")
await self._promote_to_application(row) promoted=await self._promote_to_application(row)
status=(getattr(promoted,"status",None) or "").strip()
if promoted is not None and status in ("","CLOSED","PROCESS","BANKED","REJECTED"):
from job.pipeline.views import Pipeline
try:
await Pipeline(session).change_stage(
"PENDING",current_user,manual_upload_id=promoted.id,
change_reason="Moved to shortlist from sheet forms",
)
except HTTPException as exc:
if exc.status_code!=400:
raise
updated=await FormData.set_processing_state(session,record_id,processing_state) updated=await FormData.set_processing_state(session,record_id,processing_state)
if not updated: if not updated:
raise HTTPException(status_code=404,detail="Form data not found") raise HTTPException(status_code=404,detail="Form data not found")

View File

@ -48,6 +48,7 @@ class ReadAllBody(BaseModel):
application_status: Candidate_application_Status = Candidate_application_Status.CLOSED application_status: Candidate_application_Status = Candidate_application_Status.CLOSED
assigned: bool | None = None assigned: bool | None = None
is_duplicate: bool | None = None is_duplicate: bool | None = None
processing_state: str | None = None
class TriageOverrideBody(BaseModel): class TriageOverrideBody(BaseModel):
@ -251,6 +252,7 @@ async def mark_all_inbox_read(
application_status=payload.application_status, application_status=payload.application_status,
assigned=payload.assigned, assigned=payload.assigned,
is_duplicate=payload.is_duplicate, is_duplicate=payload.is_duplicate,
processing_state=payload.processing_state,
) )
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
except HTTPException: except HTTPException:
@ -283,6 +285,7 @@ async def get_all_applications(
assigned: bool | None = Query(default=None), assigned: bool | None = Query(default=None),
is_duplicate: bool | None = Query(default=None), is_duplicate: bool | None = Query(default=None),
no_suggestions: bool | None = Query(default=None), no_suggestions: bool | None = Query(default=None),
processing_state: str | None = Query(default=None),
search: str | None = Query(None), search: str | None = Query(None),
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged. # Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
top: int | None = Query(None, ge=1, le=500), top: int | None = Query(None, ge=1, le=500),
@ -293,20 +296,20 @@ async def get_all_applications(
try: try:
service=Email(session=session) service=Email(session=session)
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED): if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions) items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions) total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
if isread==False: if isread==False:
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions) items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions) total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
if record_id: if record_id:
item=await service.get_application_by_id(record_id) item=await service.get_application_by_id(record_id)
return JSONResponse(content={"data":item,"total":1,"status_code":200}) return JSONResponse(content={"data":item,"total":1,"status_code":200})
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException: except HTTPException:
raise raise
@ -392,7 +395,7 @@ async def set_processing_state(
): ):
try: try:
service=Email(session=session) service=Email(session=session)
data=await service.set_processing_state(record_id,payload.processing_state) data=await service.set_processing_state(record_id,payload.processing_state,current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200}) return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -106,6 +106,7 @@ class Inbox(SQLModel, table=True):
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) .join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
.outerjoin(AtsResults,cls.ats_id==AtsResults.id) .outerjoin(AtsResults,cls.ats_id==AtsResults.id)
.where(Inbox_Messages.assigned_job_post_id.is_not(None)) .where(Inbox_Messages.assigned_job_post_id.is_not(None))
.where(Inbox_Messages.attachment==True) # noqa: E712
.where(Roles.role_name==EnumRoles.CANDIDATE.value) .where(Roles.role_name==EnumRoles.CANDIDATE.value)
# Newest-first is the list contract; score is only a tiebreak # Newest-first is the list contract; score is only a tiebreak
# within the same instant. id keeps paging stable. # within the same instant. id keeps paging stable.
@ -185,6 +186,7 @@ class Inbox(SQLModel, table=True):
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id) .join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) .join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
.where(Inbox_Messages.assigned_job_post_id.is_not(None)) .where(Inbox_Messages.assigned_job_post_id.is_not(None))
.where(Inbox_Messages.attachment==True) # noqa: E712
.where(Roles.role_name==EnumRoles.CANDIDATE.value) .where(Roles.role_name==EnumRoles.CANDIDATE.value)
.group_by(Inbox_Messages.application_status) .group_by(Inbox_Messages.application_status)
) )
@ -259,6 +261,64 @@ class Inbox(SQLModel, table=True):
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@classmethod
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
"""Optional department / recruiter via inbox_messages → job_posts."""
if not department and not recruiter_id:
return statement
from job.job_post.models import JobPosts
statement = (
statement
.outerjoin(Inbox_Messages, cls.message_id == Inbox_Messages.id)
.outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id)
)
if department:
statement = statement.where(JobPosts.department == department)
try:
rid = uuid.UUID(str(recruiter_id)) if recruiter_id not in (None, "") else None
except (TypeError, ValueError):
rid = None
if rid is not None:
statement = statement.where(
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
)
return statement
@classmethod
async def count_in_window(
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
):
statement = (
select(func.count())
.select_from(cls)
.join(Users, cls.user_id == Users.id)
.join(Roles, Users.role_id == Roles.id)
.where(Roles.role_name == EnumRoles.CANDIDATE.value)
)
if from_date is not None:
statement = statement.where(cls.created_at >= from_date)
if to_date is not None:
statement = statement.where(cls.created_at < to_date)
statement = cls.scoped_to_job(statement, department, recruiter_id)
result = await session.execute(statement)
return int(result.scalar_one() or 0)
@classmethod
async def counts_by_month(
cls, session: AsyncSession, start, department=None, recruiter_id=None,
):
month_bucket = func.date_trunc("month", cls.created_at)
statement = (
select(month_bucket.label("month"), func.count().label("count"))
.select_from(cls)
.where(cls.created_at >= start)
.group_by(month_bucket)
.order_by(month_bucket)
)
statement = cls.scoped_to_job(statement, department, recruiter_id)
result = await session.execute(statement)
return [(month, int(count or 0)) for month, count in result.all()]
@classmethod @classmethod
async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None): async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None):
if record_id is None: if record_id is None:
@ -270,6 +330,23 @@ class Inbox(SQLModel, table=True):
result=await session.execute(select(cls).where(cls.id==iid)) result=await session.execute(select(cls).where(cls.id==iid))
return result.scalars().first() return result.scalars().first()
@classmethod
async def get_by_ids(cls,session:AsyncSession,ids):
keys=[]
for raw in ids or []:
try:
keys.append(int(raw))
except (TypeError,ValueError):
continue
if not keys:
return []
result=await session.execute(
select(cls)
.options(selectinload(cls.messages),selectinload(cls.user))
.where(cls.id.in_(keys))
)
return list(result.scalars().all())
@classmethod @classmethod
async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None): async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None):
"""Inbox row with `messages` selectin-loaded for stage / application writers.""" """Inbox row with `messages` selectin-loaded for stage / application writers."""
@ -700,6 +777,7 @@ class Inbox_Messages(SQLModel, table=True):
assigned: bool | None=None, assigned: bool | None=None,
is_duplicate: bool | None=None, is_duplicate: bool | None=None,
no_suggestions: bool | None=None, no_suggestions: bool | None=None,
processing_state: str | None=None,
): ):
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE. """The one WHERE chain shared by the list, the count and the bulk read UPDATE.
@ -729,11 +807,16 @@ class Inbox_Messages(SQLModel, table=True):
func.jsonb_array_length(cls.suggested_job_post_ids) == 0, func.jsonb_array_length(cls.suggested_job_post_ids) == 0,
) )
) )
if processing_state:
statement = statement.where(cls.processing_state == processing_state)
# Inbox / Job Matching only list applications that arrived with a file.
# Graph hasAttachments lands on this column; body-only mail stays out.
statement = statement.where(cls.attachment == True) # noqa: E712
return statement return statement
@classmethod @classmethod
async def get_inbox_messages( async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None
): ):
# Page size is the caller's `top` (Inbox sends 10/25/50/100); `skip` is # Page size is the caller's `top` (Inbox sends 10/25/50/100); `skip` is
# (page-1)*top so page 1 of 25 -> 0..24, page 2 -> 25..49. Newest first # (page-1)*top so page 1 of 25 -> 0..24, page 2 -> 25..49. Newest first
@ -741,7 +824,7 @@ class Inbox_Messages(SQLModel, table=True):
statement = cls._apply_filters( statement = cls._apply_filters(
select(cls).order_by(cls.created_at.desc()), select(cls).order_by(cls.created_at.desc()),
search, isread, application_status, assigned, is_duplicate, search, isread, application_status, assigned, is_duplicate,
no_suggestions, no_suggestions, processing_state,
) )
if skip: if skip:
statement = statement.offset(skip) statement = statement.offset(skip)
@ -809,11 +892,11 @@ class Inbox_Messages(SQLModel, table=True):
return {str(job_id): int(n) for job_id, n in result.all()} return {str(job_id): int(n) for job_id, n in result.all()}
@classmethod @classmethod
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None): async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None):
statement = cls._apply_filters( statement = cls._apply_filters(
select(func.count()).select_from(cls), select(func.count()).select_from(cls),
search, isread, application_status, assigned, is_duplicate, search, isread, application_status, assigned, is_duplicate,
no_suggestions, no_suggestions, processing_state,
) )
result = await session.execute(statement) result = await session.execute(statement)
return result.scalar_one() return result.scalar_one()
@ -917,6 +1000,7 @@ class Inbox_Messages(SQLModel, table=True):
application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned: bool | None=None, assigned: bool | None=None,
is_duplicate: bool | None=None, is_duplicate: bool | None=None,
processing_state: str | None=None,
) -> int: ) -> int:
"""Mark every row matching a list filter. Returns rows actually CHANGED. """Mark every row matching a list filter. Returns rows actually CHANGED.
@ -925,7 +1009,7 @@ class Inbox_Messages(SQLModel, table=True):
was already read. It also keeps read_overridden_at off rows nobody decided was already read. It also keeps read_overridden_at off rows nobody decided
anything about, so the Outlook sweep keeps its reach over untouched mail. anything about, so the Outlook sweep keeps its reach over untouched mail.
""" """
statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate) statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate,processing_state=processing_state)
statement=statement.where(cls.message_read!=bool(read)) statement=statement.where(cls.message_read!=bool(read))
result=await session.execute( result=await session.execute(
statement.values(message_read=bool(read),read_overridden_at=_now()) statement.values(message_read=bool(read),read_overridden_at=_now())
@ -939,12 +1023,12 @@ class Inbox_Messages(SQLModel, table=True):
func.count().label("all_count"), func.count().label("all_count"),
func.coalesce(func.sum(case((cls.message_read == False, 1), else_=0)), 0).label("unread"), # noqa: E712 func.coalesce(func.sum(case((cls.message_read == False, 1), else_=0)), 0).label("unread"), # noqa: E712
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"), func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
func.coalesce(func.sum(case((cls.application_status == Candidate_application_Status.PROCESS, 1), else_=0)), 0).label("processed"), func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"),
func.coalesce(func.sum(case((cls.application_status == Candidate_application_Status.REJECTED, 1), else_=0)), 0).label("rejected"), func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712 func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"), func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"),
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"), func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"),
) ).where(cls.attachment == True) # noqa: E712
row = (await session.execute(statement)).one() row = (await session.execute(statement)).one()
return { return {
"all": int(row.all_count or 0), "all": int(row.all_count or 0),
@ -963,6 +1047,15 @@ class Inbox_Messages(SQLModel, table=True):
if not row: if not row:
return None return None
row.processing_state = processing_state row.processing_state = processing_state
# Pipeline Shortlist reads application_status=PENDING. CLOSED is the
# inbox default and maps to Rejected on the board — leaving it unchanged
# here is why "Move to Shortlist" never landed in Shortlist.
current = row.application_status
current_val = current.value if isinstance(current, Candidate_application_Status) else str(current or "")
if processing_state == "processed" and current_val in ("", "CLOSED", "PROCESS"):
row.application_status = Candidate_application_Status.PENDING
elif processing_state == "rejected":
row.application_status = Candidate_application_Status.REJECTED
session.add(row) session.add(row)
await session.commit() await session.commit()
await session.refresh(row) await session.refresh(row)
@ -979,6 +1072,145 @@ class Inbox_Messages(SQLModel, table=True):
await session.refresh(row) await session.refresh(row)
return row return row
@classmethod
async def count_hires_by_recruiter(
cls, session: AsyncSession, recruiter_id, *, from_date=None, to_date=None, department=None,
):
"""Messages currently HIRED and assigned to this recruiter_id."""
try:
uid = uuid.UUID(str(recruiter_id))
except (TypeError, ValueError):
return 0
from job.job_post.models import JobPosts
statement = select(func.count()).select_from(cls).where(
cls.recruiter_id == uid,
cls.application_status == Candidate_application_Status.HIRED,
)
if department:
statement = statement.outerjoin(JobPosts, cls.assigned_job_post_id == JobPosts.id).where(
JobPosts.department == department
)
if from_date is not None or to_date is not None:
statement = statement.join(Inbox, Inbox.message_id == cls.id)
if from_date is not None:
statement = statement.where(Inbox.created_at >= from_date)
if to_date is not None:
statement = statement.where(Inbox.created_at < to_date)
result = await session.execute(statement)
return int(result.scalar_one() or 0)
@classmethod
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
"""Optional department / recruiter via assigned job post."""
if not department and not recruiter_id:
return statement
from job.job_post.models import JobPosts
statement = statement.outerjoin(JobPosts, cls.assigned_job_post_id == JobPosts.id)
if department:
statement = statement.where(JobPosts.department == department)
try:
rid = uuid.UUID(str(recruiter_id)) if recruiter_id not in (None, "") else None
except (TypeError, ValueError):
rid = None
if rid is not None:
statement = statement.where(
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
)
return statement
@classmethod
def _window_by_inbox(cls, statement, from_date=None, to_date=None):
if from_date is None and to_date is None:
return statement
statement = statement.join(Inbox, Inbox.message_id == cls.id)
if from_date is not None:
statement = statement.where(Inbox.created_at >= from_date)
if to_date is not None:
statement = statement.where(Inbox.created_at < to_date)
return statement
@classmethod
async def count_hired(
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
):
"""Messages currently HIRED, windowed on inbox.created_at."""
statement = (
select(func.count())
.select_from(cls)
.join(Inbox, Inbox.message_id == cls.id)
.where(cls.application_status == Candidate_application_Status.HIRED)
)
if from_date is not None:
statement = statement.where(Inbox.created_at >= from_date)
if to_date is not None:
statement = statement.where(Inbox.created_at < to_date)
statement = cls.scoped_to_job(statement, department, recruiter_id)
result = await session.execute(statement)
return int(result.scalar_one() or 0)
@classmethod
async def counts_by_application_status(
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
):
"""Current-stage counts for the same inbox population the pipeline board uses.
Assigned-to-a-job, candidate-role only Inbox.count_by_status without a
job filter. Optional department / recruiter / created_at window sit on
top of that; with none of those this matches the board's inbox column.
"""
from job.job_post.models import JobPosts
statement = (
select(cls.application_status, func.count().label("count"))
.select_from(Inbox)
.join(Users, Inbox.user_id == Users.id)
.join(Roles, Users.role_id == Roles.id)
.join(cls, Inbox.message_id == cls.id)
.join(JobPosts, cls.assigned_job_post_id == JobPosts.id)
.where(cls.assigned_job_post_id.is_not(None))
.where(Roles.role_name == EnumRoles.CANDIDATE.value)
)
if from_date is not None:
statement = statement.where(Inbox.created_at >= from_date)
if to_date is not None:
statement = statement.where(Inbox.created_at < to_date)
if department:
statement = statement.where(JobPosts.department == department)
try:
rid = uuid.UUID(str(recruiter_id)) if recruiter_id not in (None, "") else None
except (TypeError, ValueError):
rid = None
if rid is not None:
statement = statement.where(
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
)
statement = statement.group_by(cls.application_status)
result = await session.execute(statement)
counts = {}
for status, n in result.all():
key = str(status.value if hasattr(status, "value") else status)
counts[key] = int(n or 0)
return counts
@classmethod
async def counts_by_source(
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
):
statement = (
select(
SourceChannels.id.label("source_id"),
func.coalesce(SourceChannels.label, "Unknown").label("source"),
func.count().label("count"),
)
.select_from(cls)
.outerjoin(SourceChannels, cls.source_channel_id == SourceChannels.id)
)
statement = cls.scoped_to_job(statement, department, recruiter_id)
statement = cls._window_by_inbox(statement, from_date, to_date)
statement = statement.group_by(SourceChannels.id, SourceChannels.label).order_by(func.count().desc())
result = await session.execute(statement)
return [(source_id, source, int(count or 0)) for source_id, source, count in result.all()]
class Inbox_Message_Triage(SQLModel, table=True): class Inbox_Message_Triage(SQLModel, table=True):
"""One intake verdict per upstream message id — the gate before inbox_messages. """One intake verdict per upstream message id — the gate before inbox_messages.
@ -1168,6 +1400,14 @@ class SourceChannels(SQLModel, table=True):
) )
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod
async def labels_by_ids(cls, session: AsyncSession, ids):
keys = [cid for cid in (ids or []) if cid is not None]
if not keys:
return []
result = await session.execute(select(cls.id, cls.label).where(cls.id.in_(keys)))
return [(cid, label) for cid, label in result.all()]
class AtsResults(SQLModel, table=True): class AtsResults(SQLModel, table=True):
__tablename__ = "ats_results" __tablename__ = "ats_results"
@ -1209,6 +1449,18 @@ class AtsResults(SQLModel, table=True):
) )
return result.scalars().first() return result.scalars().first()
@classmethod
async def get_by_ids(cls, session: AsyncSession, ids):
keys = []
for raw in ids or []:
uid = cls._as_uuid(raw)
if uid is not None:
keys.append(uid)
if not keys:
return []
result = await session.execute(select(cls).where(cls.id.in_(keys)))
return list(result.scalars().all())
@classmethod @classmethod
async def get_for_inbox_job(cls, session: AsyncSession, inbox_id, job_post_id): async def get_for_inbox_job(cls, session: AsyncSession, inbox_id, job_post_id):
"""Any score for this application against this job — current or superseded. """Any score for this application against this job — current or superseded.
@ -1357,6 +1609,13 @@ class MailboxSyncRun(SQLModel, table=True):
) )
return result.scalars().first() return result.scalars().first()
@classmethod
async def get_latest(cls, session: AsyncSession):
result = await session.execute(
select(cls).order_by(cls.created_at.desc()).limit(1)
)
return result.scalars().first()
@classmethod @classmethod
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True): async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
row = cls(**fields) row = cls(**fields)

View File

@ -273,13 +273,13 @@ class Email:
item["assigned_job_post"]=None item["assigned_job_post"]=None
return item return item
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None): async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED): if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
elif isread==False: elif isread==False:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
else: else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages]) urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
return [serialize_application(m,linkedin_url=urls.get(m.id)) for m in messages] return [serialize_application(m,linkedin_url=urls.get(m.id)) for m in messages]
@ -351,11 +351,7 @@ class Email:
if row: if row:
return serialize_mailbox_sync_run(row) return serialize_mailbox_sync_run(row)
# Latest finished run so the UI can still show the last result after refresh. # Latest finished run so the UI can still show the last result after refresh.
from sqlmodel import select row=await MailboxSyncRun.get_latest(self.session)
result=await self.session.execute(
select(MailboxSyncRun).order_by(MailboxSyncRun.created_at.desc()).limit(1)
)
row=result.scalars().first()
if not row: if not row:
raise HTTPException(status_code=404,detail="No sync runs yet") raise HTTPException(status_code=404,detail="No sync runs yet")
return serialize_mailbox_sync_run(row) return serialize_mailbox_sync_run(row)
@ -431,13 +427,13 @@ class Email:
results.append({"email":email,"sent":False}) results.append({"email":email,"sent":False})
return results return results
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None): async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED: if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state:
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
elif isread==False: elif isread==False:
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
else: else:
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
async def assign_job_post(self,record_id,job_post_id): async def assign_job_post(self,record_id,job_post_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
@ -495,7 +491,7 @@ class Email:
async def set_read_all(self,read,search=None,isread:bool=True, async def set_read_all(self,read,search=None,isread:bool=True,
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED, application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned=None,is_duplicate=None): assigned=None,is_duplicate=None,processing_state=None):
"""Mark every row the SAME filter set would have listed. """Mark every row the SAME filter set would have listed.
The filter arguments are the caller's current view, not a free-form query: the The filter arguments are the caller's current view, not a free-form query: the
@ -505,6 +501,7 @@ class Email:
updated=await Inbox_Messages.set_read_scope( updated=await Inbox_Messages.set_read_scope(
self.session,read,search=search,isread=isread, self.session,read,search=search,isread=isread,
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate, application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
processing_state=processing_state,
) )
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s", logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s",
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate) updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)
@ -531,10 +528,33 @@ class Email:
async def get_counts(self): async def get_counts(self):
return await Inbox_Messages.count_processing(self.session) return await Inbox_Messages.count_processing(self.session)
async def set_processing_state(self,record_id,processing_state): async def set_processing_state(self,record_id,processing_state,current_user=None):
allowed=("unread","imported","processed","rejected") allowed=("unread","imported","processed","rejected")
if processing_state not in allowed: if processing_state not in allowed:
raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}") raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}")
if processing_state=="processed":
existing=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not existing:
raise HTTPException(status_code=404,detail="Message not found")
if not existing.assigned_job_post_id:
raise HTTPException(status_code=422,detail="Assign a job post before moving to shortlist")
# Record CLOSED/PROCESS → PENDING so the board history matches the card.
current=existing.application_status
current_val=current.value if isinstance(current,Candidate_application_Status) else str(current or "")
if current_val in ("","CLOSED","PROCESS"):
link=await Inbox.get_inbox_by_message_id(self.session,existing.id)
if link is not None:
from job.pipeline.views import Pipeline
try:
await Pipeline(self.session).change_stage(
Candidate_application_Status.PENDING.value,
current_user,
inbox_id=link.id,
change_reason="Moved to shortlist from inbox",
)
except HTTPException as exc:
if exc.status_code!=400:
raise
message=await Inbox_Messages.set_processing_state(self.session,record_id,processing_state) message=await Inbox_Messages.set_processing_state(self.session,record_id,processing_state)
if not message: if not message:
raise HTTPException(status_code=404,detail="Message not found") raise HTTPException(status_code=404,detail="Message not found")

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query,Response
from fastapi.responses import FileResponse,JSONResponse from fastapi.responses import FileResponse,JSONResponse
from fastapi import HTTPException from fastapi import HTTPException
from db_setup import get_session from db_setup import get_session
from job.candidate.views import CandidateScoring,FileRead,CandidateView from job.candidate.views import CandidateScoring,FileRead,CandidateView,parse_linkedin_url_from_cv
from job.interviews.views import Interview from job.interviews.views import Interview
from job.notes.views import Note from job.notes.views import Note
from job.activity.views import ActivityLog from job.activity.views import ActivityLog
@ -33,6 +33,11 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
class MatchingAssign(BaseModel):
id: UUID
job_post_id: UUID | None = None
class CandidateUpdate(BaseModel): class CandidateUpdate(BaseModel):
favorite: bool | None = None favorite: bool | None = None
rating: float | None = None rating: float | None = None
@ -133,6 +138,8 @@ class JobUpdate(BaseModel):
experience_min: int | None = None experience_min: int | None = None
experience_max: int | None = None experience_max: int | None = None
description: str | None = None description: str | None = None
current_recruiter_id: UUID | None = None
hiring_manager_id: UUID | None = None
class JobStatusUpdate(BaseModel): class JobStatusUpdate(BaseModel):
@ -297,6 +304,7 @@ async def cv_bank_upload(
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
text=parsed.get("text") or "" text=parsed.get("text") or ""
detected,_=extract_candidate_email(text) detected,_=extract_candidate_email(text)
parsed_linkedin=await parse_linkedin_url_from_cv(text)
# Basename against both separator styles — a Windows client sends # Basename against both separator styles — a Windows client sends
# C:\Users\x\cv.pdf whose PosixPath name is the whole string. # C:\Users\x\cv.pdf whose PosixPath name is the whole string.
original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf" original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf"
@ -308,6 +316,7 @@ async def cv_bank_upload(
file_name=original, file_name=original,
created_by=current_user.get("id"), created_by=current_user.get("id"),
pdf_bytes=content, pdf_bytes=content,
linkedin_url=parsed_linkedin,
) )
try: try:
uploaded=S3().upload_for_record( uploaded=S3().upload_for_record(
@ -332,6 +341,7 @@ async def cv_bank_upload(
"file_name":row.file_name, "file_name":row.file_name,
"file_path":row.file_path or None, "file_path":row.file_path or None,
"candidate_email":row.candidate_email or None, "candidate_email":row.candidate_email or None,
"linkedin_url":row.linkedin_url or None,
"created_at":row.created_at.isoformat() if row.created_at else None, "created_at":row.created_at.isoformat() if row.created_at else None,
},"status_code":200}) },"status_code":200})
except HTTPException: except HTTPException:
@ -359,6 +369,7 @@ async def cv_bank_fetch(
"file_path":(r.file_path or "").strip() or None, "file_path":(r.file_path or "").strip() or None,
"candidate_email":r.candidate_email or None, "candidate_email":r.candidate_email or None,
"candidate_name":r.candidate_name or None, "candidate_name":r.candidate_name or None,
"linkedin_url":r.linkedin_url or None,
"created_at":r.created_at.isoformat() if r.created_at else None, "created_at":r.created_at.isoformat() if r.created_at else None,
} for r in rows] } for r in rows]
return JSONResponse(content={"data":data,"total":total,"status_code":200}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
@ -423,6 +434,61 @@ async def cv_bank_delete(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/matching/fetch")
async def matching_fetch(
top: int = Query(10, ge=1, le=500),
skip: int = Query(0, ge=0),
assigned: bool | None = Query(default=None),
search: str | None = Query(default=None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Job Matching queue: CV Import 'No job' rows (apply_via=cv_bank)."""
try:
service=CandidateView(session=session)
data,total=await service.list_matching(
assigned=assigned,search=search,limit=top,offset=skip,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/matching/fetch_by_id")
async def matching_fetch_by_id(
id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.get_matching(id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/matching/assign")
async def matching_assign(
payload: MatchingAssign,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
"""Set job_post_id on a CV-bank row — it then joins like any manual upload."""
try:
service=CandidateView(session=session)
data=await service.assign_matching(payload.id,payload.job_post_id,current_user.get("id"))
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/inbox-match") @router.post("/candidate/inbox-match")
async def candidate_inbox_match( async def candidate_inbox_match(
inbox_message_id: str = Query(...), inbox_message_id: str = Query(...),
@ -686,12 +752,46 @@ async def fetch_job_departments(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/requisition-statuses/fetch")
async def fetch_requisition_statuses(
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Hiring-lifecycle tags for the Jobs status dropdown (open/on_hold/closed/completed)."""
try:
service=JobPost(session=session)
data=await service.fetch_requisition_statuses()
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/status-history/fetch")
async def fetch_job_status_history(
job_post_id:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Who changed requisition_status on one job, from what, to what, and when."""
try:
service=JobPost(session=session)
data=await service.fetch_status_history(job_post_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/fetch") @router.get("/jobs/fetch")
async def fetch_jobs( async def fetch_jobs(
search: str | None = Query(None), search: str | None = Query(None),
department: str | None = Query(None), department: str | None = Query(None),
requisition_status: str | None = Query(None), requisition_status: str | None = Query(None),
employment_type: str | None = Query(None), employment_type: str | None = Query(None),
hiring_manager_id: str | None = Query(None),
# le=500 (not 100): the Jobs board loads a full client-side page for facets; # le=500 (not 100): the Jobs board loads a full client-side page for facets;
# a 200 ceiling used to 422 the SPA and render an empty requisition list. # a 200 ceiling used to 422 the SPA and render an empty requisition list.
top: int | None = Query(10, ge=1, le=500), top: int | None = Query(10, ge=1, le=500),
@ -707,7 +807,8 @@ async def fetch_jobs(
service=JobPost(session=session) service=JobPost(session=session)
data,total=await service.fetch_jobs( data,total=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status, search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,top=top,skip=skip,active_only=active_only, employment_type=employment_type,hiring_manager_id=hiring_manager_id,
top=top,skip=skip,active_only=active_only,
) )
return JSONResponse(content={"data":data,"total":total,"status_code":200}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException: except HTTPException:
@ -722,6 +823,7 @@ async def export_jobs(
department: str | None = Query(None), department: str | None = Query(None),
requisition_status: str | None = Query(None), requisition_status: str | None = Query(None),
employment_type: str | None = Query(None), employment_type: str | None = Query(None),
hiring_manager_id: str | None = Query(None),
active_only: bool = Query(False), active_only: bool = Query(False),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)), current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@ -731,7 +833,8 @@ async def export_jobs(
service=JobPost(session=session) service=JobPost(session=session)
data,_=await service.fetch_jobs( data,_=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status, search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,top=None,skip=0,active_only=active_only, employment_type=employment_type,hiring_manager_id=hiring_manager_id,
top=None,skip=0,active_only=active_only,
) )
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx" filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
return Response( return Response(
@ -825,6 +928,7 @@ async def fetch_interview(
from_date:datetime=Query(None), from_date:datetime=Query(None),
to_date:datetime=Query(None), to_date:datetime=Query(None),
status:str=Query(None), status:str=Query(None),
recruiter_id:str=Query(None),
top:int=Query(None), top:int=Query(None),
skip:int=Query(0,ge=0), skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
@ -832,14 +936,14 @@ async def fetch_interview(
): ):
try: try:
service=Interview(session=session) service=Interview(session=session)
if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or top is not None): if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or recruiter_id or top is not None):
data,total=await service.get_interviews_range( data,total=await service.get_interviews_range(
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip,
) )
return JSONResponse(content={"data":data,"total":total,"status_code":200}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
data=await service.get_interview( data=await service.get_interview(
interview_id=interview_id,inbox_id=inbox_id, interview_id=interview_id,inbox_id=inbox_id,
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip,
) )
if isinstance(data,tuple): if isinstance(data,tuple):
data,total=data data,total=data
@ -1104,12 +1208,16 @@ async def fetch_pipeline_transitions(
@router.get("/job/assignments/fetch") @router.get("/job/assignments/fetch")
async def fetch_job_assignments( async def fetch_job_assignments(
job_post_id:str=Query(...), job_post_id:str=Query(...),
current_only:bool=Query(True),
assignment_role:str=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
try: try:
service=Assignment(session=session) service=Assignment(session=session)
data=await service.list_job_assignments(job_post_id) data=await service.list_job_assignments(
job_post_id,current_only=current_only,assignment_role=assignment_role,
)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -40,17 +40,48 @@ class JobAssignments(SQLModel, table=True):
return result.scalars().first() return result.scalars().first()
@classmethod @classmethod
async def fetch_by_job(cls, session: AsyncSession, job_post_id, *, current_only: bool = True): async def fetch_by_job(
cls,
session: AsyncSession,
job_post_id,
*,
current_only: bool = True,
assignment_role: str | None = None,
):
uid = cls._as_uuid(job_post_id) uid = cls._as_uuid(job_post_id)
if uid is None: if uid is None:
return [] return []
statement = select(cls).where(cls.job_post_id == uid) statement = select(cls).where(cls.job_post_id == uid)
if current_only: if current_only:
statement = statement.where(cls.valid_to.is_(None)) statement = statement.where(cls.valid_to.is_(None))
if assignment_role:
statement = statement.where(cls.assignment_role == assignment_role)
statement = statement.order_by(cls.valid_from.desc()) statement = statement.order_by(cls.valid_from.desc())
result = await session.execute(statement) result = await session.execute(statement)
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod
async def close_current(cls, session: AsyncSession, job_post_id, assignment_role):
"""End every open interval of this role on the job. Returns how many closed."""
uid = cls._as_uuid(job_post_id)
if uid is None or not assignment_role:
return 0
statement = select(cls).where(
cls.job_post_id == uid,
cls.assignment_role == assignment_role,
cls.valid_to.is_(None),
)
result = await session.execute(statement)
rows = list(result.scalars().all())
if not rows:
return 0
now = _now()
for row in rows:
row.valid_to = now
session.add(row)
await session.commit()
return len(rows)
@classmethod @classmethod
async def insert_assignment(cls, session: AsyncSession, fields: dict): async def insert_assignment(cls, session: AsyncSession, fields: dict):
row = cls(**fields) row = cls(**fields)
@ -74,6 +105,7 @@ class JobAssignments(SQLModel, table=True):
@classmethod @classmethod
async def count_open_reqs_by_users(cls, session: AsyncSession, user_ids): async def count_open_reqs_by_users(cls, session: AsyncSession, user_ids):
"""Open requisitions per user: current assignments joined to open job_posts.""" """Open requisitions per user: current assignments joined to open job_posts."""
from job.job_post.enums import RequisitionStatus
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
uids = [u for u in (user_ids or []) if u] uids = [u for u in (user_ids or []) if u]
@ -86,7 +118,7 @@ class JobAssignments(SQLModel, table=True):
.where( .where(
cls.user_id.in_(uids), cls.user_id.in_(uids),
cls.valid_to.is_(None), cls.valid_to.is_(None),
JobPosts.requisition_status == "open", JobPosts.requisition_status == RequisitionStatus.OPEN.value,
JobPosts.is_deleted == False, # noqa: E712 JobPosts.is_deleted == False, # noqa: E712
) )
.group_by(cls.user_id) .group_by(cls.user_id)

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 { return {
"id": str(row.id), "id": str(row.id),
"job_post_id": str(row.job_post_id) if row.job_post_id else None, "job_post_id": str(row.job_post_id) if row.job_post_id else None,
"user_id": str(row.user_id) if row.user_id else None, "user_id": user_key,
"user_name": names.get(user_key) if user_key else None,
"assignment_role": row.assignment_role, "assignment_role": row.assignment_role,
"valid_from": row.valid_from.isoformat() if row.valid_from else None, "valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None, "valid_to": row.valid_to.isoformat() if row.valid_to else None,
"assigned_by": str(row.assigned_by) if row.assigned_by else None, "assigned_by": by_key,
"assigned_by_name": names.get(by_key) if by_key else None,
"created_at": row.created_at.isoformat() if row.created_at else None, "created_at": row.created_at.isoformat() if row.created_at else None,
} }
def serialize_application_assignment(row) -> dict: def serialize_application_assignment(row, names=None) -> dict:
names = names or {}
user_key = str(row.user_id) if row.user_id else None
by_key = str(row.assigned_by) if row.assigned_by else None
return { return {
"id": str(row.id), "id": str(row.id),
"inbox_id": row.inbox_id, "inbox_id": row.inbox_id,
"user_id": str(row.user_id) if row.user_id else None, "user_id": user_key,
"user_name": names.get(user_key) if user_key else None,
"assignment_role": row.assignment_role, "assignment_role": row.assignment_role,
"valid_from": row.valid_from.isoformat() if row.valid_from else None, "valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None, "valid_to": row.valid_to.isoformat() if row.valid_to else None,
"assigned_by": str(row.assigned_by) if row.assigned_by else None, "assigned_by": by_key,
"assigned_by_name": names.get(by_key) if by_key else None,
"created_at": row.created_at.isoformat() if row.created_at else None, "created_at": row.created_at.isoformat() if row.created_at else None,
} }

View File

@ -3,58 +3,129 @@ from sqlalchemy.ext.asyncio import AsyncSession
from job.assignment.models import ApplicationAssignments, JobAssignments from job.assignment.models import ApplicationAssignments, JobAssignments
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
from job.job_post.models import JobPosts
from role.models import EnumRoles, Roles from role.models import EnumRoles, Roles
from users.models import Users from users.models import Users
# job_assignments.assignment_role → the users.role that may hold it.
# primary_recruiter is swappable; hiring_manager is the requisition owner.
JOB_ASSIGNMENT_ROLES = {
"primary_recruiter": EnumRoles.RECRUITER,
"hiring_manager": EnumRoles.HIRING_MANAGER,
}
JOB_OWNER_COLUMN = {
"primary_recruiter": "current_recruiter_id",
"hiring_manager": "hiring_manager_id",
}
class Assignment: class Assignment:
def __init__(self,session:AsyncSession): def __init__(self,session:AsyncSession):
self.session=session self.session=session
async def _require_recruiter(self,user_id): async def require_role(self,user_id,role_enum,field_name):
role=await Roles.get_role_by_name(self.session,EnumRoles.RECRUITER.value) role=await Roles.get_role_by_name(self.session,role_enum.value)
user=await Users.get_user_by_id(self.session,user_id) user=await Users.get_user_by_id(self.session,user_id)
if not role or not user or user.role_id!=role.id: if not role or not user or user.role_id!=role.id:
raise HTTPException(status_code=422,detail="user_id must be a recruiter") raise HTTPException(
status_code=422,
detail=f"{field_name} must be a {role_enum.value}",
)
if not user.is_active or user.is_deleted:
raise HTTPException(status_code=422,detail=f"{field_name} is not an active user")
return user return user
async def list_job_assignments(self,job_post_id): def _job_role(self,raw):
key=(raw or "primary_recruiter").strip()
if key=="recruiter":
key="primary_recruiter"
if key not in JOB_ASSIGNMENT_ROLES:
allowed=", ".join(sorted(JOB_ASSIGNMENT_ROLES))
raise HTTPException(
status_code=422,
detail=f"assignment_role must be one of {allowed}",
)
return key
async def record_job_owner(self,job_post_id,user_id,assignment_role,assigned_by):
"""Close the open interval of this role, then open a new one.
user_id None = unassign (hiring_manager cannot be cleared; callers
must not pass None for that role). No-ops when the same person already
holds the open interval. Does not touch job_posts columns.
"""
role=self._job_role(assignment_role)
job_uid=JobAssignments._as_uuid(job_post_id)
by_uid=JobAssignments._as_uuid(assigned_by)
if not job_uid or not by_uid:
raise HTTPException(status_code=422,detail="Invalid job_post_id or assigned_by")
current=await JobAssignments.fetch_by_job(
self.session,job_uid,current_only=True,assignment_role=role,
)
if user_id is None:
if role=="hiring_manager":
raise HTTPException(status_code=422,detail="hiring_manager_id is required")
await JobAssignments.close_current(self.session,job_uid,role)
return None
user_uid=JobAssignments._as_uuid(user_id)
if not user_uid:
raise HTTPException(status_code=422,detail="Invalid user_id")
if current and str(current[0].user_id)==str(user_uid):
return current[0]
await JobAssignments.close_current(self.session,job_uid,role)
return await JobAssignments.insert_assignment(self.session,{
"job_post_id":job_uid,
"user_id":user_uid,
"assignment_role":role,
"assigned_by":by_uid,
})
async def list_job_assignments(self,job_post_id,current_only=True,assignment_role=None):
if not job_post_id: if not job_post_id:
raise HTTPException(status_code=400,detail="job_post_id is required") raise HTTPException(status_code=400,detail="job_post_id is required")
rows=await JobAssignments.fetch_by_job(self.session,job_post_id) role=self._job_role(assignment_role) if assignment_role else None
return [serialize_job_assignment(r) for r in rows] rows=await JobAssignments.fetch_by_job(
self.session,job_post_id,current_only=current_only,assignment_role=role,
)
names=await Users.names_by_ids(
self.session,[r.user_id for r in rows]+[r.assigned_by for r in rows],
)
return [serialize_job_assignment(r,names=names) for r in rows]
async def create_job_assignment(self,payload,current_user): async def create_job_assignment(self,payload,current_user):
user_id=payload.get("user_id") user_id=payload.get("user_id")
job_post_id=payload.get("job_post_id") job_post_id=payload.get("job_post_id")
if not user_id or not job_post_id: if not user_id or not job_post_id:
raise HTTPException(status_code=422,detail="user_id and job_post_id are required") raise HTTPException(status_code=422,detail="user_id and job_post_id are required")
await self._require_recruiter(user_id) job=await JobPosts.get_job_post_by_id(self.session,job_post_id)
fields={ if not job or job.is_deleted:
"job_post_id":JobAssignments._as_uuid(job_post_id), raise HTTPException(status_code=404,detail="Job post not found")
"user_id":JobAssignments._as_uuid(user_id), role=self._job_role(payload.get("assignment_role"))
"assignment_role":payload.get("assignment_role") or "primary_recruiter", await self.require_role(user_id,JOB_ASSIGNMENT_ROLES[role],"user_id")
"assigned_by":JobAssignments._as_uuid( assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
current_user.get("id") if isinstance(current_user,dict) else None row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
), column=JOB_OWNER_COLUMN[role]
} await JobPosts.update_job_post(self.session,job_post_id,{column:user_id})
if not fields["job_post_id"] or not fields["user_id"] or not fields["assigned_by"]: names=await Users.names_by_ids(
raise HTTPException(status_code=422,detail="Invalid job_post_id, user_id, or assigned_by") self.session,[row.user_id,row.assigned_by] if row else [],
row=await JobAssignments.insert_assignment(self.session,fields) )
return serialize_job_assignment(row) return serialize_job_assignment(row,names=names) if row else None
async def list_application_assignments(self,inbox_id): async def list_application_assignments(self,inbox_id):
if inbox_id is None: if inbox_id is None:
raise HTTPException(status_code=400,detail="inbox_id is required") raise HTTPException(status_code=400,detail="inbox_id is required")
rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id)) rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id))
return [serialize_application_assignment(r) for r in rows] names=await Users.names_by_ids(
self.session,[r.user_id for r in rows]+[r.assigned_by for r in rows],
)
return [serialize_application_assignment(r,names=names) for r in rows]
async def create_application_assignment(self,payload,current_user): async def create_application_assignment(self,payload,current_user):
user_id=payload.get("user_id") user_id=payload.get("user_id")
inbox_id=payload.get("inbox_id") inbox_id=payload.get("inbox_id")
if not user_id or inbox_id is None: if not user_id or inbox_id is None:
raise HTTPException(status_code=422,detail="user_id and inbox_id are required") raise HTTPException(status_code=422,detail="user_id and inbox_id are required")
await self._require_recruiter(user_id) await self.require_role(user_id,EnumRoles.RECRUITER,"user_id")
fields={ fields={
"inbox_id":int(inbox_id), "inbox_id":int(inbox_id),
"user_id":ApplicationAssignments._as_uuid(user_id), "user_id":ApplicationAssignments._as_uuid(user_id),
@ -66,4 +137,5 @@ class Assignment:
if not fields["user_id"] or not fields["assigned_by"]: if not fields["user_id"] or not fields["assigned_by"]:
raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by") raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by")
row=await ApplicationAssignments.insert_assignment(self.session,fields) row=await ApplicationAssignments.insert_assignment(self.session,fields)
return serialize_application_assignment(row) names=await Users.names_by_ids(self.session,[row.user_id,row.assigned_by])
return serialize_application_assignment(row,names=names)

View File

@ -3,7 +3,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, func, or_ from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -175,6 +175,45 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return counts return counts
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@classmethod
async def counts_by_application_status(
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
):
"""Current-stage counts for the same manual-upload population the board uses.
Inner-joined to a job post, same as count_by_status. Optional department /
recruiter / created_at window sit on top; with none of those this matches
the board's manual_upload column.
"""
from users.models import Users
from job.job_post.models import JobPosts
qry=(
select(cls.status,func.count())
.select_from(cls)
.join(Users,cls.user_id==Users.id)
.join(JobPosts,cls.job_post_id==JobPosts.id)
)
if from_date is not None:
qry=qry.where(cls.created_at>=from_date)
if to_date is not None:
qry=qry.where(cls.created_at<to_date)
if department:
qry=qry.where(JobPosts.department==department)
try:
rid=uuid.UUID(str(recruiter_id)) if recruiter_id not in (None,"") else None
except (TypeError,ValueError):
rid=None
if rid is not None:
qry=qry.where(JobPosts.current_recruiter_id==rid)
qry=qry.group_by(cls.status)
result=await session.execute(qry)
counts={}
for status,n in result.all():
key=(status or "").strip() or "UNKNOWN"
counts[key]=counts.get(key,0)+int(n or 0)
return counts
@staticmethod @staticmethod
def _as_uuid(record_id) -> uuid.UUID | None: def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""): if record_id in (None, ""):
@ -290,6 +329,18 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
result = await session.execute(select(cls).where(cls.id == uid)) result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first() return result.scalars().first()
@classmethod
async def get_by_ids(cls, session: AsyncSession, ids):
keys = []
for raw in ids or []:
uid = cls._as_uuid(raw)
if uid is not None:
keys.append(uid)
if not keys:
return []
result = await session.execute(select(cls).where(cls.id.in_(keys)))
return list(result.scalars().all())
@classmethod @classmethod
async def get_by_email_and_job(cls, session: AsyncSession, email: str, job_post_id): async def get_by_email_and_job(cls, session: AsyncSession, email: str, job_post_id):
"""Idempotency for form / re-import promotes against the same role.""" """Idempotency for form / re-import promotes against the same role."""
@ -380,25 +431,27 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return out return out
# ---- CV bank ----------------------------------------------------------- # ---- CV bank -----------------------------------------------------------
# apply_via="cv_bank" rows are a private store of CVs with NO job, NO user # apply_via="cv_bank" marks origin: the CV Import "No job" tab. Unassigned
# account and NO inbox entry — deliberately invisible to Candidates, # rows (job_post_id IS NULL) are the bank; Job Matching assigns a job_post_id
# Pipeline (whose list inner-joins Users/JobPosts) and the Inbox. They wait # (and a user account) so get_all's Users/JobPosts inner joins pick them up
# until a recruiter picks them up; email is captured only when the CV # as normal applications. apply_via stays "cv_bank" so Matching can still
# contains one. # list them. No inbox entry.
@classmethod @classmethod
async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email, async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email,
candidate_name, full_text, file_name, candidate_name, full_text, file_name,
created_by, pdf_bytes, created_by, pdf_bytes,
content_type="application/pdf"): content_type="application/pdf",
linkedin_url=None):
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit. """Bank a CV: metadata row + its bytes (cv_bank_files) in one commit.
file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload). file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload).
When the CV carries an email, the candidate ACCOUNT is created/reused linkedin_url is the employment-agent extraction (None when the CV has
(same pattern as create_manual_upload_candidate) so the person shows none never a constructed slug). When the CV carries an email, the
up on the Candidates screen; unlike an application there is still no candidate ACCOUNT is created/reused so the person shows up on the
inbox entry, no scoring, and no setup email. A CV with no detectable Candidates screen; unlike an application there is still no inbox entry,
email banks fine and simply stays account-less.""" no scoring, and no setup email. A CV with no detectable email banks
fine and simply stays account-less."""
import os import os
from role.models import EnumRoles, Roles from role.models import EnumRoles, Roles
@ -427,12 +480,21 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
user.is_active = True user.is_active = True
session.add(user) session.add(user)
url = (linkedin_url or "").strip() or None
if url:
linkedin_slug = slug_from_url(url) or NO_SLUG
else:
linkedin_slug = primary_slug_from_text(full_text or "")
if user and url:
await Users.set_linkedin_url_if_empty(session, user_id=user.id, url=url)
row = cls( row = cls(
candidate_email=email, candidate_email=email,
candidate_name=(candidate_name or "").strip() or (email or ""), candidate_name=(candidate_name or "").strip() or (email or ""),
job_post_id=None, job_post_id=None,
full_text=full_text or "", full_text=full_text or "",
linkedin_slug=primary_slug_from_text(full_text or ""), linkedin_slug=linkedin_slug,
linkedin_url=url,
apply_via="cv_bank", apply_via="cv_bank",
user_id=user.id if user else None, user_id=user.id if user else None,
created_by=cls._as_uuid(created_by), created_by=cls._as_uuid(created_by),
@ -455,14 +517,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
@classmethod @classmethod
async def list_bank(cls, session: AsyncSession, limit=100, offset=0): async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
"""Unassigned No-job CVs only — assigned rows leave the bank for Matching."""
bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None))
total = ( total = (
await session.execute( await session.execute(
select(func.count()).select_from(cls).where(cls.apply_via == "cv_bank") select(func.count()).select_from(cls).where(*bank)
) )
).scalar() or 0 ).scalar() or 0
result = await session.execute( result = await session.execute(
select(cls) select(cls)
.where(cls.apply_via == "cv_bank") .where(*bank)
.order_by(cls.created_at.desc(), cls.id.desc()) .order_by(cls.created_at.desc(), cls.id.desc())
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
@ -470,12 +534,104 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return list(result.scalars().all()), total return list(result.scalars().all()), total
@classmethod @classmethod
async def delete_bank_cv(cls, session: AsyncSession, record_id): async def list_matching(cls, session: AsyncSession, *, assigned=None,
"""Hard delete, bank rows only — never reachable for application rows. search=None, limit=100, offset=0):
The cv_bank_files row goes with it via ON DELETE CASCADE.""" """No-job-tab origin (`apply_via=cv_bank`). `assigned` is tri-valued:
None = all, False = still in the bank, True = job_post_id set."""
filters = [cls.apply_via == "cv_bank"]
if assigned is True:
filters.append(cls.job_post_id.is_not(None))
elif assigned is False:
filters.append(cls.job_post_id.is_(None))
if search and str(search).strip():
like = f"%{str(search).strip()}%"
filters.append(or_(
cls.candidate_name.ilike(like),
cls.candidate_email.ilike(like),
cls.file_name.ilike(like),
))
total = (
await session.execute(
select(func.count()).select_from(cls).where(*filters)
)
).scalar() or 0
result = await session.execute(
select(cls)
.where(*filters)
.order_by(cls.created_at.desc(), cls.id.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all()), total
@classmethod
async def _ensure_bank_user(cls, session: AsyncSession, row):
"""Candidate USER so get_all / Candidates can see the row after assign.
insert_user commits; caller must reload `row` afterwards."""
if row.user_id:
return None
import os
from role.models import EnumRoles, Roles
from users.models import Users
from users.plugins import hash_password
email = (row.candidate_email or "").strip().lower()
if not email:
email = f"cvbank-{row.id.hex}@no-email.local"
user = await Users.get_user_by_email(session, email)
if not user:
role = await Roles.get_role_by_name(session, EnumRoles.CANDIDATE.value)
name = (row.candidate_name or "").strip() or (row.file_name or "").strip() or email
user = await Users.insert_user(session, {
"name": name,
"email": email,
"role_id": role.id if role else 8,
"password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")),
"is_active": True,
"is_approved": True,
"is_deleted": False,
})
elif user.is_deleted or not user.is_active:
user.is_deleted = False
user.is_active = True
session.add(user)
return user
@classmethod
async def assign_job_post(cls, session: AsyncSession, record_id, job_post_id):
"""Set job_post_id on a CV-bank row. None unassigns (back to the bank).
Origin apply_via stays cv_bank. Creates/reuses a candidate user so the
row joins like any other manual_upload_candidate application."""
row = await cls.get_by_id(session, record_id) row = await cls.get_by_id(session, record_id)
if not row or row.apply_via != "cv_bank": if not row or row.apply_via != "cv_bank":
return None return None
jid = cls._as_uuid(job_post_id) if job_post_id not in (None, "") else None
user = await cls._ensure_bank_user(session, row)
row = await cls.get_by_id(session, record_id)
if not row:
return None
if user is not None:
row.user_id = user.id
if not (row.candidate_email or "").strip() and user.email:
row.candidate_email = user.email
if not (row.candidate_name or "").strip() and user.name:
row.candidate_name = user.name
row.job_post_id = jid
row.status = "PENDING" if jid else "BANKED"
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def delete_bank_cv(cls, session: AsyncSession, record_id):
"""Hard delete unassigned bank rows only — assigned rows are applications.
The cv_bank_files row goes with it via ON DELETE CASCADE."""
row = await cls.get_by_id(session, record_id)
if not row or row.apply_via != "cv_bank" or row.job_post_id is not None:
return None
file_row = await CvBankFiles.get(session, row.id) file_row = await CvBankFiles.get(session, row.id)
if file_row: if file_row:
await session.delete(file_row) await session.delete(file_row)
@ -685,6 +841,11 @@ class Interviews(SQLModel, table=True):
interview_type: str = Field(default="") interview_type: str = Field(default="")
interview_status: str = Field(default="") interview_status: str = Field(default="")
inbox_id: int | None = Field(default=None, foreign_key="inbox.id") inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
# Optional denorm so Recruiter Hub can join interviews → job_posts.current_recruiter_id
# without walking inbox. Filled on create from the application's assigned job;
# migration 011 added the columns. user_id is the candidate, not the recruiter.
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
graph_event_id: str | None = Field(default=None) graph_event_id: str | None = Field(default=None)
web_link: str | None = Field(default=None) web_link: str | None = Field(default=None)
inbox: Optional["Inbox"] = Relationship( inbox: Optional["Inbox"] = Relationship(
@ -725,6 +886,28 @@ class Interviews(SQLModel, table=True):
) )
return result.scalars().all() return result.scalars().all()
@classmethod
def scoped_to_recruiter(cls, statement, recruiter_id):
"""Restrict an Interviews select to the recruiter who owns the job.
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id)
job_posts.current_recruiter_id. Interviews with no job drop out.
"""
from inbox.models import Inbox, Inbox_Messages
from job.job_post.models import JobPosts
rid = cls._as_uuid(recruiter_id)
if rid is None:
return statement
job_id = func.coalesce(cls.job_post_id, Inbox_Messages.assigned_job_post_id)
return (
statement
.outerjoin(Inbox, cls.inbox_id == Inbox.id)
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
.outerjoin(JobPosts, JobPosts.id == job_id)
.where(JobPosts.current_recruiter_id == rid)
)
@classmethod @classmethod
async def get_interviews_in_range( async def get_interviews_in_range(
cls, cls,
@ -733,6 +916,7 @@ class Interviews(SQLModel, table=True):
from_date=None, from_date=None,
to_date=None, to_date=None,
status: str | None = None, status: str | None = None,
recruiter_id=None,
top: int | None = None, top: int | None = None,
skip: int = 0, skip: int = 0,
): ):
@ -743,6 +927,8 @@ class Interviews(SQLModel, table=True):
statement = statement.where(cls.interview_date < to_date) statement = statement.where(cls.interview_date < to_date)
if status: if status:
statement = statement.where(cls.interview_status == status) statement = statement.where(cls.interview_status == status)
if recruiter_id:
statement = cls.scoped_to_recruiter(statement, recruiter_id)
count_statement = select(func.count()).select_from(statement.subquery()) count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one() total = (await session.execute(count_statement)).scalar_one()
statement = ( statement = (
@ -755,6 +941,41 @@ class Interviews(SQLModel, table=True):
result = await session.execute(statement) result = await session.execute(statement)
return list(result.scalars().all()), total return list(result.scalars().all()), total
@classmethod
async def count_between(cls, session: AsyncSession, start, end, recruiter_id=None):
statement = select(func.count()).select_from(cls).where(
cls.interview_date >= start,
cls.interview_date < end,
)
if recruiter_id:
statement = cls.scoped_to_recruiter(statement, recruiter_id)
result = await session.execute(statement)
return int(result.scalar_one() or 0)
@classmethod
async def count_upcoming(cls, session: AsyncSession, as_of, recruiter_id=None):
statement = select(func.count()).select_from(cls).where(
cls.interview_status.ilike("scheduled"),
cls.interview_date >= as_of,
)
if recruiter_id:
statement = cls.scoped_to_recruiter(statement, recruiter_id)
result = await session.execute(statement)
return int(result.scalar_one() or 0)
@classmethod
async def next_scheduled_at(cls, session: AsyncSession, as_of, recruiter_id=None):
statement = select(
func.min(func.coalesce(cls.interview_time, cls.interview_date))
).select_from(cls).where(
cls.interview_status.ilike("scheduled"),
cls.interview_date >= as_of,
)
if recruiter_id:
statement = cls.scoped_to_recruiter(statement, recruiter_id)
result = await session.execute(statement)
return result.scalar_one()
@classmethod @classmethod
async def job_titles_by_inbox(cls, session: AsyncSession, inbox_ids) -> dict[int, str]: async def job_titles_by_inbox(cls, session: AsyncSession, inbox_ids) -> dict[int, str]:
"""Resolve {inbox_id: job_title} for a page of interview rows. """Resolve {inbox_id: job_title} for a page of interview rows.
@ -1117,6 +1338,113 @@ class ApplicationStageTransitions(SQLModel, table=True):
result = await session.execute(statement) result = await session.execute(statement)
return result.scalar_one() return result.scalar_one()
@classmethod
async def avg_time_to_hire(
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
):
"""Mean days from first stage (from_stage IS NULL) to HIRED, optional filters."""
from inbox.enums import Candidate_application_Status
from inbox.models import Inbox, Inbox_Messages
from job.job_post.models import JobPosts
entry = cls.__table__.alias("entry")
hire = cls.__table__.alias("hire")
days = func.extract("epoch", hire.c.valid_from - entry.c.valid_from) / 86400.0
statement = (
select(func.avg(days))
.select_from(
hire.join(
entry,
and_(
hire.c.inbox_id == entry.c.inbox_id,
entry.c.from_stage.is_(None),
),
)
)
.where(hire.c.to_stage == Candidate_application_Status.HIRED.value)
)
if from_date is not None:
statement = statement.where(hire.c.valid_from >= from_date)
if to_date is not None:
statement = statement.where(hire.c.valid_from < to_date)
if department or recruiter_id:
statement = (
statement
.outerjoin(Inbox, hire.c.inbox_id == Inbox.id)
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
.outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id)
)
if department:
statement = statement.where(JobPosts.department == department)
rid = cls._as_uuid(recruiter_id)
if rid is not None:
statement = statement.where(
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
)
result = await session.execute(statement)
value = result.scalar_one()
return float(value) if value is not None else None
@classmethod
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
if not department and not recruiter_id:
return statement
from inbox.models import Inbox, Inbox_Messages
from job.job_post.models import JobPosts
statement = (
statement
.outerjoin(Inbox, cls.inbox_id == Inbox.id)
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
.outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id)
)
if department:
statement = statement.where(JobPosts.department == department)
rid = cls._as_uuid(recruiter_id)
if rid is not None:
statement = statement.where(
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
)
return statement
@classmethod
async def count_hires(
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
):
from inbox.enums import Candidate_application_Status
statement = select(func.count()).select_from(cls).where(
cls.to_stage == Candidate_application_Status.HIRED.value
)
if from_date is not None:
statement = statement.where(cls.valid_from >= from_date)
if to_date is not None:
statement = statement.where(cls.valid_from < to_date)
statement = cls.scoped_to_job(statement, department, recruiter_id)
result = await session.execute(statement)
return int(result.scalar_one() or 0)
@classmethod
async def counts_hires_by_month(
cls, session: AsyncSession, start, department=None, recruiter_id=None,
):
from inbox.enums import Candidate_application_Status
month_bucket = func.date_trunc("month", cls.valid_from)
statement = (
select(month_bucket.label("month"), func.count().label("count"))
.select_from(cls)
.where(
cls.to_stage == Candidate_application_Status.HIRED.value,
cls.valid_from >= start,
)
.group_by(month_bucket)
.order_by(month_bucket)
)
statement = cls.scoped_to_job(statement, department, recruiter_id)
result = await session.execute(statement)
return [(month, int(count or 0)) for month, count in result.all()]
class CandidateHistory(SQLModel, table=True): class CandidateHistory(SQLModel, table=True):
"""Append-only audit log for one candidate (users.id), scoped to an application. """Append-only audit log for one candidate (users.id), scoped to an application.

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]: def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
return { return {
"id":str(row.id) if row.id else None, "id":str(row.id) if row.id else None,

View File

@ -5,9 +5,6 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import HTTPException from fastapi import HTTPException
from pypdf import PdfReader from pypdf import PdfReader
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlmodel import true
from app.core.errors import ATSError,ErrorCode from app.core.errors import ATSError,ErrorCode
from app.models.scoring import CompletedCandidate from app.models.scoring import CompletedCandidate
from app.services.pdf import extract_resume,sanitize_filename from app.services.pdf import extract_resume,sanitize_filename
@ -26,7 +23,7 @@ from job.candidate.plugins import (
get_scoring_settings, get_scoring_settings,
normalize_spaced_text, normalize_spaced_text,
) )
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post from job.job_post.serializers import serialize_job_post
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
@ -46,7 +43,7 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
async def parse_linkedin_url_from_cv(resume_text) -> str | None: async def parse_linkedin_url_from_cv(resume_text) -> str | None:
"""Employment-agent `linkedin_url` key from parsed CV text. None if absent or the call fails.""" """Employment-agent `linkedin_url` from CV text. None if absent, sentinel, invented, or the call fails."""
text=(resume_text or "").strip() text=(resume_text or "").strip()
if not text: if not text:
return None return None
@ -988,22 +985,13 @@ class CandidateView:
notes=[] notes=[]
uid=Notes._as_uuid(user_id) if user_id else None uid=Notes._as_uuid(user_id) if user_id else None
if uid is not None: if uid is not None:
result=await self.session.execute( notes=[serialize_note(r) for r in await Notes.get_notes_by_user(self.session,uid)]
select(Notes)
.options(selectinload(Notes.author))
.where(Notes.user_id==uid)
.order_by(Notes.created_at.desc())
)
notes=[serialize_note(r) for r in result.scalars().all()]
# ATS score from inbox denorm / ats_results via Inbox.ats_id — never from # ATS score from inbox denorm / ats_results via Inbox.ats_id — never from
# a Candidates join on message id. Keywords live on the scored Candidates # a Candidates join on message id. Keywords live on the scored Candidates
# row: candidate_id when set, else email+job for the matched-user path. # row: candidate_id when set, else email+job for the matched-user path.
ats_ids=[r.ats_id for r in records if getattr(r,"ats_id",None)] ats_ids=[r.ats_id for r in records if getattr(r,"ats_id",None)]
ats_rows=[] ats_rows=await AtsResults.get_by_ids(self.session,ats_ids) if ats_ids else []
if ats_ids:
result=await self.session.execute(select(AtsResults).where(AtsResults.id.in_(ats_ids)))
ats_rows=list(result.scalars().all())
assigned_uid=AtsResults._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None assigned_uid=AtsResults._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
chosen=None chosen=None
if assigned_uid is not None: if assigned_uid is not None:
@ -1088,3 +1076,52 @@ class CandidateView:
raise HTTPException(status_code=404,detail="Not found") raise HTTPException(status_code=404,detail="Not found")
name=(entry.get("name") or path.name).strip() or path.name name=(entry.get("name") or path.name).strip() or path.name
return path,name return path,name
async def list_matching(self,assigned=None,search=None,limit=10,offset=0):
rows,total=await Manual_UPLOAD_CANDIDATE.list_matching(
self.session,assigned=assigned,search=search,limit=limit,offset=offset,
)
job_ids=[str(r.job_post_id) for r in rows if r.job_post_id]
posts=await JobPosts.get_by_ids(self.session,job_ids,active_only=False) if job_ids else []
by_id={str(p.id):p for p in posts}
data=[
serialize_matching_candidate(r,by_id.get(str(r.job_post_id)) if r.job_post_id else None)
for r in rows
]
return data,total
async def get_matching(self,record_id):
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id)
if not row or row.apply_via!="cv_bank":
raise HTTPException(status_code=404,detail="CV not found")
job_post=None
if row.job_post_id:
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
return serialize_matching_candidate(row,job_post)
async def assign_matching(self,record_id,job_post_id,current_user=None):
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id)
if not row or row.apply_via!="cv_bank":
raise HTTPException(status_code=404,detail="CV not found")
job_post=None
if job_post_id not in (None,""):
job_post=await JobPosts.get_job_post_by_id(self.session,str(job_post_id))
if not job_post or job_post.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
was_unassigned=row.job_post_id is None
row=await Manual_UPLOAD_CANDIDATE.assign_job_post(self.session,record_id,job_post_id)
if not row:
raise HTTPException(status_code=404,detail="CV not found")
if job_post is None and row.job_post_id:
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
if was_unassigned and row.job_post_id and row.user_id:
title=(job_post.title if job_post else "") or str(row.job_post_id)
await HistoryRecorder(self.session).record(
HistoryEvent.CANDIDATE_CREATED.value,
actor_id=current_user,user_id=row.user_id,
manual_upload_candidate_id=row.id,
entity_type="manual_upload_candidate",entity_id=row.id,
to_value=title,
description="cv_bank",commit=True,
)
return serialize_matching_candidate(row,job_post)

View File

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

View File

@ -1,7 +1,5 @@
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import select
from job.candidate.models import Feedback from job.candidate.models import Feedback
from job.feedback.models import FeedbackTemplates from job.feedback.models import FeedbackTemplates
@ -15,13 +13,7 @@ class FeedbackView:
self.session=session self.session=session
async def _load(self,record_id): async def _load(self,record_id):
uid=Feedback._as_uuid(record_id) return await Feedback.get_feedback_by_id(self.session,record_id)
if uid is None:
return None
result=await self.session.execute(
select(Feedback).options(selectinload(Feedback.user)).where(Feedback.id==uid)
)
return result.scalars().first()
async def get_feedback(self,feedback_id=None,inbox_id=None): async def get_feedback(self,feedback_id=None,inbox_id=None):
if feedback_id: if feedback_id:
@ -31,13 +23,8 @@ class FeedbackView:
return serialize_feedback(row) return serialize_feedback(row)
if inbox_id is None: if inbox_id is None:
raise HTTPException(status_code=400,detail="feedback_id or inbox_id is required") raise HTTPException(status_code=400,detail="feedback_id or inbox_id is required")
result=await self.session.execute( rows=await Feedback.get_feedback_by_inbox(self.session,int(inbox_id))
select(Feedback) return [serialize_feedback(r) for r in rows]
.options(selectinload(Feedback.user))
.where(Feedback.inbox_id==int(inbox_id))
.order_by(Feedback.created_at.desc())
)
return [serialize_feedback(r) for r in result.scalars().all()]
async def create_feedback(self,payload,current_user): async def create_feedback(self,payload,current_user):
fields={ fields={

View File

@ -2,7 +2,6 @@ import logging
from datetime import datetime from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from inbox.models import Inbox from inbox.models import Inbox
from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE
@ -117,10 +116,5 @@ class HistoryRecorder:
self.session, user_id, limit=limit, offset=offset self.session, user_id, limit=limit, offset=offset
) )
actor_ids = {r.actor_id for r in rows if r.actor_id} actor_ids = {r.actor_id for r in rows if r.actor_id}
names = {} names = await Users.names_by_ids(self.session, actor_ids)
if actor_ids: return [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows], total
result = await self.session.execute(
select(Users.id, Users.name).where(Users.id.in_(actor_ids))
)
names = {uid: name for uid, name in result.all()}
return [serialize_history(r, actor_name=names.get(r.actor_id)) for r in rows], total

View File

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

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 BRAND_STRIPE = "EFF7F2" # zebra row tint
BORDER_TINT = "CBDCD2" BORDER_TINT = "CBDCD2"
STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold"} STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold", "completed": "Completed"}
STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700"} STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700", "completed": "0F6E56"}
# (header, column width) # (header, column width)
COLUMNS = [ COLUMNS = [
@ -36,6 +36,7 @@ COLUMNS = [
("Status", 10), ("Status", 10),
("Publishing", 12), ("Publishing", 12),
("Recruiter", 18), ("Recruiter", 18),
("Hiring Manager", 18),
("Created By", 18), ("Created By", 18),
("Created", 13), ("Created", 13),
("Requirements", 46), ("Requirements", 46),
@ -126,6 +127,7 @@ def build_jobs_workbook(rows) -> bytes:
STATUS_LABELS.get(status_key, status_key), STATUS_LABELS.get(status_key, status_key),
row.get("status") or "", row.get("status") or "",
row.get("recruiter_name") or "", row.get("recruiter_name") or "",
row.get("hiring_manager_name") or "",
row.get("created_by_name") or "", row.get("created_by_name") or "",
_created(row), _created(row),
_bullets(row.get("requirements")), _bullets(row.get("requirements")),
@ -145,7 +147,7 @@ def build_jobs_workbook(rows) -> bytes:
status_cell.alignment = center status_cell.alignment = center
if status_key in STATUS_COLORS: if status_key in STATUS_COLORS:
status_cell.font = Font(bold=True, color=STATUS_COLORS[status_key]) status_cell.font = Font(bold=True, color=STATUS_COLORS[status_key])
created_cell = ws.cell(row=r, column=13) created_cell = ws.cell(row=r, column=14)
if created_cell.value is not None: if created_cell.value is not None:
created_cell.number_format = "dd mmm yyyy" created_cell.number_format = "dd mmm yyyy"
for c in (14, 15, 16): for c in (14, 15, 16):

View File

@ -2,10 +2,12 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, JSON, func, or_ from sqlalchemy import DateTime, JSON, Index, func, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select from sqlmodel import Field, Relationship, SQLModel, select
from job.job_post.enums import RequisitionStatus
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
from users.models import Users from users.models import Users
@ -20,12 +22,12 @@ class JobPosts(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str = Field(index=True) title: str = Field(index=True)
# foreign_keys is required, not decoration: current_recruiter_id below is a # foreign_keys is required, not decoration: current_recruiter_id and
# SECOND foreign key into users.id, so the join condition is ambiguous without # hiring_manager_id below are extra FKs into users.id, so the join is
# it and every mapper fails to initialize. `user` is the AUTHOR of the post — # ambiguous without it and every mapper fails to initialize. `user` is the
# current_recruiter_id is deliberately a bare column with no relationship of # AUTHOR of the post. The recruiter and hiring-manager columns stay bare —
# its own, because Users already carries five selectin relations that load on # Users already carries five selectin relations that load on every
# every authenticated request. Same pairing as Notes.user / Notes.author. # authenticated request. Same pairing as Notes.user / Notes.author.
user: Optional["Users"] = Relationship( user: Optional["Users"] = Relationship(
back_populates="job_posts", back_populates="job_posts",
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"}, sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
@ -49,14 +51,19 @@ class JobPosts(SQLModel, table=True):
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
status: str = Field(default="draft") status: str = Field(default="draft")
buffer_error: str | None = Field(default=None) buffer_error: str | None = Field(default=None)
# requisition_status is the hiring lifecycle (open/closed/on_hold). Distinct from # requisition_status is the hiring lifecycle (RequisitionStatus). Distinct from
# `status`, which tracks Buffer publishing (draft/scheduled/published/failed). # `status`, which tracks Buffer publishing (draft/scheduled/published/failed).
# server_default is load-bearing: this column arrives as an ALTER on a populated table. # server_default is load-bearing: this column arrives as an ALTER on a populated table.
requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"}) requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"})
department: str = Field(default="", sa_column_kwargs={"server_default": ""}) department: str = Field(default="", sa_column_kwargs={"server_default": ""})
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
# Who is working the req now (swappable). History lives in job_assignments
# with assignment_role=primary_recruiter; this column is the current pointer.
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
# Who owns the requisition (stable). Required at create. History lives in
# job_assignments with assignment_role=hiring_manager.
hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True)
created_by: uuid.UUID = Field(foreign_key="users.id") created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@ -136,6 +143,7 @@ class JobPosts(SQLModel, table=True):
department: str | None = None, department: str | None = None,
requisition_status: str | None = None, requisition_status: str | None = None,
employment_type: str | None = None, employment_type: str | None = None,
hiring_manager_id: uuid.UUID | None = None,
): ):
if ids: if ids:
rows = await cls.get_by_ids(session, ids, active_only=active_only) rows = await cls.get_by_ids(session, ids, active_only=active_only)
@ -157,6 +165,8 @@ class JobPosts(SQLModel, table=True):
statement = statement.where(cls.requisition_status == requisition_status) statement = statement.where(cls.requisition_status == requisition_status)
if employment_type: if employment_type:
statement = statement.where(cls.employment_type == employment_type) statement = statement.where(cls.employment_type == employment_type)
if hiring_manager_id is not None:
statement = statement.where(cls.hiring_manager_id == hiring_manager_id)
count_statement = select(func.count()).select_from(statement.subquery()) count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one() total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.created_at.desc()) statement = statement.order_by(cls.created_at.desc())
@ -185,10 +195,118 @@ class JobPosts(SQLModel, table=True):
result = await session.execute(statement) result = await session.execute(statement)
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
"""Open requisitions per hiring manager, keyed by users.id."""
uids = [u for u in (user_ids or []) if u]
if not uids:
return {}
statement = (
select(cls.hiring_manager_id, func.count())
.where(
cls.hiring_manager_id.in_(uids),
cls.requisition_status == "open",
cls.is_deleted == False, # noqa: E712
)
.group_by(cls.hiring_manager_id)
)
result = await session.execute(statement)
return {uid: int(n or 0) for uid, n in result.all()}
@classmethod
async def count_by_current_recruiter(
cls, session: AsyncSession, recruiter_id, *, status, department=None,
from_date=None, to_date=None,
):
"""Requisitions owned by current_recruiter_id in one requisition_status."""
uid = cls._as_uuid(recruiter_id)
if uid is None:
return 0
statement = select(func.count()).select_from(cls).where(
cls.current_recruiter_id == uid,
cls.requisition_status == status,
cls.is_deleted == False, # noqa: E712
)
if department:
statement = statement.where(cls.department == department)
if from_date is not None:
statement = statement.where(cls.closed_at >= from_date)
if to_date is not None:
statement = statement.where(cls.closed_at < to_date)
result = await session.execute(statement)
return int(result.scalar_one() or 0)
@classmethod
def _scoped(cls, statement, department=None, recruiter_id=None):
if department:
statement = statement.where(cls.department == department)
uid = cls._as_uuid(recruiter_id) if recruiter_id is not None else None
if uid is not None:
statement = statement.where(cls.current_recruiter_id == uid)
return statement
@classmethod
async def count_requisitions(
cls, session: AsyncSession, status=None, department=None, recruiter_id=None,
from_date=None, to_date=None, *, closed_in_window=False,
):
statement = select(func.count()).select_from(cls).where(cls.is_deleted == False) # noqa: E712
if status:
statement = statement.where(cls.requisition_status == status)
statement = cls._scoped(statement, department, recruiter_id)
if closed_in_window:
if from_date is not None:
statement = statement.where(cls.closed_at >= from_date)
if to_date is not None:
statement = statement.where(cls.closed_at < to_date)
result = await session.execute(statement)
return int(result.scalar_one() or 0)
@classmethod
async def count_open_snapshot(cls, session: AsyncSession, as_of, department=None, recruiter_id=None):
"""Jobs that existed and were still open at `as_of` (best-effort)."""
statement = select(func.count()).select_from(cls).where(
cls.is_deleted == False, # noqa: E712
cls.created_at < as_of,
or_(cls.closed_at.is_(None), cls.closed_at >= as_of),
cls.requisition_status == RequisitionStatus.OPEN.value,
)
statement = cls._scoped(statement, department, recruiter_id)
result = await session.execute(statement)
return int(result.scalar_one() or 0)
@classmethod
async def avg_time_to_fill(
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
):
days = func.extract("epoch", cls.closed_at - cls.created_at) / 86400.0
statement = select(func.avg(days)).select_from(cls).where(
cls.is_deleted == False, # noqa: E712
cls.requisition_status.in_((
RequisitionStatus.CLOSED.value,
RequisitionStatus.COMPLETED.value,
)),
cls.closed_at.is_not(None),
)
if from_date is not None:
statement = statement.where(cls.closed_at >= from_date)
if to_date is not None:
statement = statement.where(cls.closed_at < to_date)
statement = cls._scoped(statement, department, recruiter_id)
result = await session.execute(statement)
value = result.scalar_one()
return float(value) if value is not None else None
@classmethod @classmethod
async def insert_job_post(cls, session: AsyncSession, fields: dict): async def insert_job_post(cls, session: AsyncSession, fields: dict):
row = cls(**fields) row = cls(**fields)
session.add(row) session.add(row)
session.add(JobPostStatusHistory(
job_post_id=row.id,
from_status=None,
to_status=row.requisition_status or "open",
changed_by=row.created_by,
))
await session.commit() await session.commit()
return await cls.get_job_post_by_id(session, row.id) return await cls.get_job_post_by_id(session, row.id)
@ -263,23 +381,68 @@ class JobPosts(SQLModel, table=True):
return row return row
@classmethod @classmethod
async def set_requisition_status(cls, session: AsyncSession, record_id: str, status: str): async def set_requisition_status(
cls, session: AsyncSession, record_id: str, status: str, *, changed_by=None,
):
row = await cls.get_job_post_by_id(session, record_id) row = await cls.get_job_post_by_id(session, record_id)
if not row or row.is_deleted: if not row or row.is_deleted:
return None return None
previous = row.requisition_status previous = row.requisition_status
if previous == status:
return row
row.requisition_status = status row.requisition_status = status
if status == "closed": terminal = status in ("closed", "completed")
if previous != "closed" or row.closed_at is None: if terminal:
if previous not in ("closed", "completed") or row.closed_at is None:
row.closed_at = _now() row.closed_at = _now()
else: else:
row.closed_at = None row.closed_at = None
row.updated_at = _now() row.updated_at = _now()
session.add(row) session.add(row)
actor = cls._as_uuid(changed_by) if changed_by is not None else None
session.add(JobPostStatusHistory(
job_post_id=row.id,
from_status=previous,
to_status=status,
changed_by=actor,
))
await session.commit() await session.commit()
return await cls.get_job_post_by_id(session, record_id) return await cls.get_job_post_by_id(session, record_id)
class JobPostStatusHistory(SQLModel, table=True):
"""Who changed job_posts.requisition_status, from what, to what, and when.
Distinct from job_assignments (ownership intervals). The Jobs History tab
merges both. Applied on prod by migrations/manual/016_job_post_status_history.sql.
"""
__tablename__ = "job_post_status_history"
__table_args__ = (
Index("ix_job_post_status_history_job_created", "job_post_id", "created_at"),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
job_post_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True)
from_status: str | None = Field(default=None)
to_status: str
changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
actor_kind: str = Field(default="user")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod
async def fetch_by_job(cls, session: AsyncSession, job_post_id):
uid = JobPosts._as_uuid(job_post_id)
if uid is None:
return []
result = await session.execute(
select(cls)
.where(cls.job_post_id == uid)
.order_by(cls.created_at.desc(), cls.id.desc())
)
return list(result.scalars().all())
class JobPostImages(SQLModel, table=True): class JobPostImages(SQLModel, table=True):
"""Cover image of a job post, stored as bytes IN the database. """Cover image of a job post, stored as bytes IN the database.

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: def serialize_job_post(row) -> dict:
return { return {
"id": str(row.id), "id": str(row.id),
"title": row.title, "title": row.title,
# Talent Pool / candidate filters key off attached job_posts.department.
"department": row.department or None,
"employment_type": row.employment_type, "employment_type": row.employment_type,
"location": row.location, "location": row.location,
"experience_min": row.experience_min, "experience_min": row.experience_min,
@ -27,12 +39,12 @@ def serialize_job_post(row) -> dict:
} }
def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict: def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
"""Requisition view of a job post, for the Jobs screen. """Requisition view of a job post, for the Jobs screen.
Deliberately separate from serialize_job_post: that payload is shared by the Deliberately separate from serialize_job_post: that payload is shared by the
inbox, candidate and matching paths, and widening it would change five inbox, candidate and matching paths. department is the one shared field
response shapes at once. talent-pool filters key off it on attached job_posts.
""" """
return { return {
"id": str(row.id), "id": str(row.id),
@ -56,9 +68,26 @@ def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict:
"closed_at": row.closed_at.isoformat() if row.closed_at else None, "closed_at": row.closed_at.isoformat() if row.closed_at else None,
"current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None, "current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None,
"recruiter_name": recruiter_name, "recruiter_name": recruiter_name,
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
"hiring_manager_name": hiring_manager_name,
"applicant_count": applicant_count, "applicant_count": applicant_count,
"created_by": str(row.created_by) if row.created_by else None, "created_by": str(row.created_by) if row.created_by else None,
"created_by_name": row.user.name if getattr(row, "user", None) else None, "created_by_name": row.user.name if getattr(row, "user", None) else None,
"created_at": row.created_at.isoformat() if row.created_at else None, "created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None,
} }
def serialize_status_history(row, *, changed_by_name=None) -> dict:
return {
"id": str(row.id),
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"from_status": row.from_status,
"from_label": _status_label(row.from_status),
"to_status": row.to_status,
"to_label": _status_label(row.to_status),
"changed_by": str(row.changed_by) if row.changed_by else None,
"changed_by_name": changed_by_name,
"actor_kind": row.actor_kind,
"created_at": row.created_at.isoformat() if row.created_at else None,
}

View File

@ -3,6 +3,7 @@ import logging
import os import os
import uuid import uuid
from pathlib import Path from pathlib import Path
from uuid import UUID
import httpx import httpx
from dotenv import load_dotenv from dotenv import load_dotenv
@ -10,7 +11,10 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from inbox.models import Inbox_Messages from inbox.models import Inbox_Messages
from job.job_post.models import JobPostImages,JobPosts,SocialPlatform from job.assignment.views import Assignment
from job.job_post.enums import RequisitionStatus
from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform
from role.models import EnumRoles
from users.models import Users from users.models import Users
from job.job_post.plugins import ( from job.job_post.plugins import (
BufferError, BufferError,
@ -22,7 +26,7 @@ from job.job_post.plugins import (
render_job_post, render_job_post,
resolve_channel, resolve_channel,
) )
from job.job_post.serializers import serialize_job_post, serialize_job_row from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_status_history
load_dotenv() load_dotenv()
logger=logging.getLogger("job.job_post") logger=logging.getLogger("job.job_post")
@ -60,6 +64,8 @@ class JobPostCreate(BaseModel):
scheduler_time: time | None = time(0, 0, 0) scheduler_time: time | None = time(0, 0, 0)
scheduler_date: date | None = None scheduler_date: date | None = None
due_at: str | None = None due_at: str | None = None
hiring_manager_id: UUID
current_recruiter_id: UUID | None = None
@model_validator(mode="after") @model_validator(mode="after")
def validate_mode_and_due_at(self): def validate_mode_and_due_at(self):
@ -138,7 +144,24 @@ class JobPost:
# Column default is "linkedin"; an unpublished requisition must not # Column default is "linkedin"; an unpublished requisition must not
# masquerade as a LinkedIn post. # masquerade as a LinkedIn post.
fields["platform"]="internal" fields["platform"]="internal"
assignment=Assignment(self.session)
hm=await assignment.require_role(
payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id",
)
fields["hiring_manager_id"]=hm.id
rec=None
if payload.get("current_recruiter_id"):
rec=await assignment.require_role(
payload.get("current_recruiter_id"),EnumRoles.RECRUITER,"current_recruiter_id",
)
fields["current_recruiter_id"]=rec.id
row=await JobPosts.insert_job_post(self.session,fields) row=await JobPosts.insert_job_post(self.session,fields)
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by)
if rec:
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
if not publish: if not publish:
return serialize_job_post(row) return serialize_job_post(row)
@ -186,21 +209,42 @@ class JobPost:
async def fetch_departments(self,active_only=False): async def fetch_departments(self,active_only=False):
return await JobPosts.list_departments(self.session,active_only=active_only) return await JobPosts.list_departments(self.session,active_only=active_only)
async def fetch_requisition_statuses(self):
return RequisitionStatus.as_list()
async def fetch_status_history(self,job_post_id):
job=await JobPosts.get_job_post_by_id(self.session,job_post_id)
if not job or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id)
names=await Users.names_by_ids(self.session,[r.changed_by for r in rows])
return [
serialize_status_history(r,changed_by_name=names.get(str(r.changed_by)))
for r in rows
]
async def fetch_jobs(self,search=None,department=None,requisition_status=None, async def fetch_jobs(self,search=None,department=None,requisition_status=None,
employment_type=None,top=None,skip=0,active_only=True): employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True):
hm_uid=None
if hiring_manager_id:
hm_uid=JobPosts._as_uuid(hiring_manager_id)
if hm_uid is None:
raise HTTPException(status_code=422,detail="hiring_manager_id must be a UUID")
rows,total=await JobPosts.fetch_job_posts( rows,total=await JobPosts.fetch_job_posts(
self.session,search=search,top=top,skip=skip,active_only=active_only, self.session,search=search,top=top,skip=skip,active_only=active_only,
department=department,requisition_status=requisition_status, department=department,requisition_status=requisition_status,
employment_type=employment_type, employment_type=employment_type,hiring_manager_id=hm_uid,
) )
names=await Users.names_by_ids( names=await Users.names_by_ids(
self.session,[r.current_recruiter_id for r in rows], self.session,
[r.current_recruiter_id for r in rows]+[r.hiring_manager_id for r in rows],
) )
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows]) counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
return [ return [
serialize_job_row( serialize_job_row(
r, r,
recruiter_name=names.get(str(r.current_recruiter_id)), recruiter_name=names.get(str(r.current_recruiter_id)),
hiring_manager_name=names.get(str(r.hiring_manager_id)),
applicant_count=counts.get(str(r.id),0), applicant_count=counts.get(str(r.id),0),
) )
for r in rows for r in rows
@ -208,13 +252,21 @@ class JobPost:
async def _job_row(self,row): async def _job_row(self,row):
names=await Users.names_by_ids( names=await Users.names_by_ids(
self.session,[row.current_recruiter_id] if row.current_recruiter_id else [], self.session,
[row.current_recruiter_id,row.hiring_manager_id],
)
return serialize_job_row(
row,
recruiter_name=names.get(str(row.current_recruiter_id)),
hiring_manager_name=names.get(str(row.hiring_manager_id)),
) )
return serialize_job_row(row,recruiter_name=names.get(str(row.current_recruiter_id)))
async def update_job(self,job_post_id,payload,current_user): async def update_job(self,job_post_id,payload,current_user):
if not current_user: if not current_user:
raise HTTPException(status_code=401,detail="Not authenticated") raise HTTPException(status_code=401,detail="Not authenticated")
existing=await JobPosts.get_job_post_by_id(self.session,job_post_id)
if not existing or existing.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
allowed=("title","department","location","employment_type","vacancies", allowed=("title","department","location","employment_type","vacancies",
"salary","experience_min","experience_max","description") "salary","experience_min","experience_max","description")
fields={k:payload[k] for k in allowed if k in payload} fields={k:payload[k] for k in allowed if k in payload}
@ -229,11 +281,41 @@ class JobPost:
fields["salary"]=str(high) fields["salary"]=str(high)
if "department" in fields and fields["department"] is None: if "department" in fields and fields["department"] is None:
fields["department"]="" fields["department"]=""
assignment=Assignment(self.session)
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
hm_changed=False
rec_changed=False
if "hiring_manager_id" in payload:
raw=payload.get("hiring_manager_id")
if not raw:
raise HTTPException(status_code=422,detail="hiring_manager_id is required")
hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id")
fields["hiring_manager_id"]=hm.id
hm_changed=str(existing.hiring_manager_id)!=str(hm.id)
if "current_recruiter_id" in payload:
raw=payload.get("current_recruiter_id")
if raw is None or raw=="":
fields["current_recruiter_id"]=None
rec_changed=existing.current_recruiter_id is not None
else:
rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_id")
fields["current_recruiter_id"]=rec.id
rec_changed=str(existing.current_recruiter_id)!=str(rec.id)
if not fields: if not fields:
raise HTTPException(status_code=400,detail="No fields to update") raise HTTPException(status_code=400,detail="No fields to update")
row=await JobPosts.update_job_post(self.session,job_post_id,fields) row=await JobPosts.update_job_post(self.session,job_post_id,fields)
if not row: if not row:
raise HTTPException(status_code=404,detail="Job post not found") raise HTTPException(status_code=404,detail="Job post not found")
if hm_changed:
await assignment.record_job_owner(
job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by,
)
if rec_changed:
await assignment.record_job_owner(
job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by,
)
return await self._job_row(row) return await self._job_row(row)
async def delete_job(self,job_post_id,current_user): async def delete_job(self,job_post_id,current_user):
@ -285,13 +367,20 @@ class JobPost:
if not current_user: if not current_user:
raise HTTPException(status_code=401,detail="Not authenticated") raise HTTPException(status_code=401,detail="Not authenticated")
status=(payload.get("requisition_status") or "").strip() status=(payload.get("requisition_status") or "").strip()
allowed=("open","closed","on_hold") parsed=RequisitionStatus.parse(status)
if status not in allowed: if parsed is None:
raise HTTPException(status_code=422,detail=f"requisition_status must be one of {', '.join(allowed)}") raise HTTPException(
row=await JobPosts.set_requisition_status(self.session,job_post_id,status) status_code=422,
detail=f"requisition_status must be one of {', '.join(RequisitionStatus.values())}",
)
status=parsed.value
actor=current_user.get("id") if isinstance(current_user,dict) else None
row=await JobPosts.set_requisition_status(
self.session,job_post_id,status,changed_by=actor,
)
if not row: if not row:
raise HTTPException(status_code=404,detail="Job post not found") raise HTTPException(status_code=404,detail="Job post not found")
if status=="closed": if status==RequisitionStatus.CLOSED.value:
try: try:
from notifications.models import Notifications from notifications.models import Notifications
raw=row.current_recruiter_id or (current_user.get("id") if current_user else None) raw=row.current_recruiter_id or (current_user.get("id") if current_user else None)

View File

@ -1,7 +1,5 @@
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import select
from job.candidate.models import Notes from job.candidate.models import Notes
from job.history.enums import HistoryEvent from job.history.enums import HistoryEvent
@ -14,13 +12,7 @@ class Note:
self.session=session self.session=session
async def _load(self,record_id): async def _load(self,record_id):
uid=Notes._as_uuid(record_id) return await Notes.get_note_by_id(self.session,record_id)
if uid is None:
return None
result=await self.session.execute(
select(Notes).options(selectinload(Notes.author)).where(Notes.id==uid)
)
return result.scalars().first()
async def get_note(self,note_id=None,user_id=None): async def get_note(self,note_id=None,user_id=None):
if note_id: if note_id:
@ -33,13 +25,8 @@ class Note:
uid=Notes._as_uuid(user_id) uid=Notes._as_uuid(user_id)
if uid is None: if uid is None:
raise HTTPException(status_code=400,detail="Invalid user_id") raise HTTPException(status_code=400,detail="Invalid user_id")
result=await self.session.execute( rows=await Notes.get_notes_by_user(self.session,uid)
select(Notes) return [serialize_note(r) for r in rows]
.options(selectinload(Notes.author))
.where(Notes.user_id==uid)
.order_by(Notes.created_at.desc())
)
return [serialize_note(r) for r in result.scalars().all()]
async def create_note(self,payload,current_user): async def create_note(self,payload,current_user):
fields={ fields={

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

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")) row["avg_time_to_hire"] = _round(row.get("avg_time_to_hire"))
columns = [ columns = [
{"key": "name", "label": "Recruiter"}, {"key": "name", "label": "Recruiter"},
{"key": "completed", "label": "Completed Requisitions"},
{"key": "hires", "label": "Hires"}, {"key": "hires", "label": "Hires"},
{"key": "open_reqs", "label": "Open Requisitions"}, {"key": "open_reqs", "label": "Open Requisitions"},
{"key": "avg_time_to_hire", "label": "Avg Time to Hire (days)"}, {"key": "avg_time_to_hire", "label": "Avg Time to Hire (days)"},

View File

@ -258,6 +258,16 @@ class Roles(SQLModel, table=True):
result = await session.execute(statement) result = await session.execute(statement)
return result.scalars().first() return result.scalars().first()
@classmethod
async def get_by_names(cls, session: AsyncSession, names):
keys = [n for n in (names or []) if n]
if not keys:
return []
result = await session.execute(
select(cls).where(cls.role_name.in_(keys), cls.is_deleted == False) # noqa: E712
)
return list(result.scalars().all())
@classmethod @classmethod
async def count_roles(cls, session: AsyncSession, search: str | None): async def count_roles(cls, session: AsyncSession, search: str | None):
statement = ( statement = (

View File

@ -2,9 +2,7 @@ import uuid
from datetime import timezone from datetime import timezone
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from role.models import Roles from role.models import Roles
from tasks.models import Tasks from tasks.models import Tasks
@ -55,10 +53,7 @@ class Task:
ids = [i for i in set(ids) if i] ids = [i for i in set(ids) if i]
if not ids: if not ids:
return {} return {}
result = await self.session.execute( return {u.id: u for u in await Users.get_by_ids(self.session, ids)}
select(Users).options(selectinload(Users.role)).where(Users.id.in_(ids))
)
return {u.id: u for u in result.scalars().all()}
async def _validate_assignee(self, assignee_id): async def _validate_assignee(self, assignee_id):
"""Assignees must be recruiter-role accounts (role resolved from the DB): """Assignees must be recruiter-role accounts (role resolved from the DB):
@ -78,10 +73,8 @@ class Task:
"""Creation is limited to system admin / HR admin / recruiter. The role """Creation is limited to system admin / HR admin / recruiter. The role
ids are looked up from the roles table, and the permission-tag guard on ids are looked up from the roles table, and the permission-tag guard on
the route (tasks.create) still applies on top of this.""" the route (tasks.create) still applies on top of this."""
result = await self.session.execute( rows = await Roles.get_by_names(self.session, CREATOR_ROLES)
select(Roles).where(Roles.role_name.in_(CREATOR_ROLES)) allowed_ids = {r.id for r in rows}
)
allowed_ids = {r.id for r in result.scalars().all()}
if current_user.get("role_id") not in allowed_ids: if current_user.get("role_id") not in allowed_ids:
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,

View File

@ -246,7 +246,12 @@ async def delete_user(
@router.get("/managers/fetch") @router.get("/managers/fetch")
async def fetch_managers( async def fetch_managers(
current_user: dict = Depends( current_user: dict = Depends(
require_permission(PermissionTag.JOBS_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False) require_permission(
PermissionTag.JOBS_VIEW,
PermissionTag.CANDIDATES_VIEW,
PermissionTag.JOB_BOARD_CREATE,
require_all=False,
)
), ),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):

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 # selectin, not joined: this is a one-to-many, so a joined load would repeat the
# user row once per post. Without an explicit strategy the default is a lazy load, # user row once per post. Without an explicit strategy the default is a lazy load,
# which raises MissingGreenlet the moment anything touches it under asyncio. # which raises MissingGreenlet the moment anything touches it under asyncio.
# foreign_keys must match the other side: job_posts.current_recruiter_id is a # foreign_keys must match the other side: job_posts also has current_recruiter_id
# second FK into this table, so this relation has to say it means created_by. # and hiring_manager_id into this table, so this relation has to say created_by.
job_posts: List[JobPosts] = Relationship( job_posts: List[JobPosts] = Relationship(
back_populates="user", back_populates="user",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"}, sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
@ -117,6 +117,23 @@ class Users(SQLModel, table=True):
result = await session.execute(statement) result = await session.execute(statement)
return result.scalars().all() return result.scalars().all()
@classmethod
async def list_by_role_name(cls, session: AsyncSession, role_name, user_id=None):
"""Active (non-deleted) users whose Roles.role_name matches. Optional id filter."""
statement = (
select(cls)
.join(Roles, cls.role_id == Roles.id)
.where(
Roles.role_name == role_name,
cls.is_deleted == False, # noqa: E712
)
)
uid = cls._as_uuid(user_id) if user_id is not None else None
if uid is not None:
statement = statement.where(cls.id == uid)
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod @classmethod
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]: async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
"""Resolve {user_id: name} in a single query. """Resolve {user_id: name} in a single query.
@ -133,6 +150,21 @@ class Users(SQLModel, table=True):
) )
return {str(uid): name for uid, name in result.all()} return {str(uid): name for uid, name in result.all()}
@classmethod
async def get_by_ids(cls, session: AsyncSession, ids):
"""Users with role selectin-loaded. UUID keys so callers can map by row.assignee_id."""
uids = []
for raw in ids or []:
uid = cls._as_uuid(raw)
if uid is not None:
uids.append(uid)
if not uids:
return []
result = await session.execute(
select(cls).options(selectinload(cls.role)).where(cls.id.in_(uids))
)
return list(result.scalars().all())
@classmethod @classmethod
async def get_user_by_id(cls, session: AsyncSession, record_id: str): async def get_user_by_id(cls, session: AsyncSession, record_id: str):
uid = cls._as_uuid(record_id) uid = cls._as_uuid(record_id)

View File

@ -114,13 +114,13 @@ class User:
async def get_managers(self): async def get_managers(self):
"""Hiring-manager directory for Jobs/Candidates callers who do not hold rbac_users.view.""" """Hiring-manager directory for Jobs/Candidates callers who do not hold rbac_users.view."""
from job.assignment.models import JobAssignments from job.job_post.models import JobPosts
role=await Roles.get_role_by_name(self.session,EnumRoles.HIRING_MANAGER.value) role=await Roles.get_role_by_name(self.session,EnumRoles.HIRING_MANAGER.value)
if role is None: if role is None:
raise HTTPException(status_code=500,detail="Role hiring_manager is not seeded") raise HTTPException(status_code=500,detail="Role hiring_manager is not seeded")
rows=await Users.get_users(self.session,top=500,role_id=role.id) rows=await Users.get_users(self.session,top=500,role_id=role.id)
counts=await JobAssignments.count_open_reqs_by_users(self.session,[u.id for u in rows]) counts=await JobPosts.count_open_reqs_by_hiring_managers(self.session,[u.id for u in rows])
data=[ data=[
{ {
"id": str(u.id), "id": str(u.id),

View File

@ -1,4 +1,5 @@
import { request } from '../lib/apiClient' import { request } from '../lib/apiClient'
import { toStageCounts } from './pipeline'
/** /**
* Dashboard analytics aggregates backend/analytics/app.py. * Dashboard analytics aggregates backend/analytics/app.py.
@ -27,6 +28,28 @@ export function funnel({ fromDate, toDate, department, recruiterId } = {}) {
}) })
} }
/** Board column order used by the pipeline page. Rejected is last so callers
* that drop outcomes can slice it off without re-sorting. */
const BOARD_STAGE_ORDER = [
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
'Approved', 'Hired', 'On Hold', 'Rejected',
]
/**
* Fold /analytics/funnel/fetch rows (11 enum statuses) onto the pipeline
* columns. Same mapping the board uses, so CLOSED is Rejected and ONHOLD /
* APPROVED keep their own columns rather than folding into Screening / Hired.
*/
export function toBoardStageRows(funnelRows, { includeRejected = false } = {}) {
const folded = toStageCounts(
Object.fromEntries((funnelRows || []).map((r) => [r.stage, r.count || 0])),
)
const order = includeRejected
? BOARD_STAGE_ORDER
: BOARD_STAGE_ORDER.filter((s) => s !== 'Rejected')
return order.map((stage) => ({ stage, count: folded[stage] || 0 }))
}
export function hiringTrend({ months = 7, fromDate, toDate, department, recruiterId } = {}) { export function hiringTrend({ months = 7, fromDate, toDate, department, recruiterId } = {}) {
return request('/analytics/hiring-trend/fetch', { return request('/analytics/hiring-trend/fetch', {
params: { params: {

View File

@ -4,23 +4,28 @@ import { request } from '../lib/apiClient'
assignments.js who owns a requisition, and who owns an application. assignments.js who owns a requisition, and who owns an application.
Two parallel tables behind four routes (backend/job/app.py): Two parallel tables behind four routes (backend/job/app.py):
job_assignments a recruiter on a JOB POST (jobs.view / jobs.edit) job_assignments recruiter OR hiring manager on a JOB POST
application_assignments a recruiter on ONE APPLICATION (candidates.view / candidates.edit) application_assignments a recruiter on ONE APPLICATION
Rows are valid-time intervals: `valid_to === null` is the assignment in force Rows are valid-time intervals: `valid_to === null` is the assignment in force
now, and the fetch routes return only those by default. There is no unassign now. Fetch defaults to current-only; pass currentOnly: false for the history
or reassign route `insert_assignment` closes the previous open interval and log. Reassignment closes the previous open interval of the SAME role.
opens a new one, so assigning someone else IS the reassignment.
The server rejects any user whose role is not `recruiter` with a 422 Recruiter pickers use /tasks/assignees/fetch; hiring-manager pickers use
(Assignment._require_recruiter), which is why every picker here is sourced /managers/fetch. Neither needs rbac_users.view. The current pointers also
from /tasks/assignees/fetch the one endpoint that already returns exactly live on job_posts (current_recruiter_id, hiring_manager_id) and PATCH
the active recruiter-role users, and needs no rbac_users.view to call. /jobs/update is the Jobs-screen write path.
============================================================ */ ============================================================ */
/** Current recruiter(s) on one requisition. */ /** Current or historical owners of one requisition. */
export function listJob(jobPostId) { export function listJob(jobPostId, { currentOnly, assignmentRole } = {}) {
return request('/job/assignments/fetch', { params: { job_post_id: jobPostId } }) return request('/job/assignments/fetch', {
params: {
job_post_id: jobPostId,
current_only: currentOnly,
assignment_role: assignmentRole,
},
})
} }
/** Assign a recruiter to a requisition. Supersedes whoever held it. */ /** Assign a recruiter to a requisition. Supersedes whoever held it. */
@ -60,7 +65,8 @@ export function toAssignmentView(row, namesById) {
return { return {
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
name: namesById?.get(String(row.user_id)) ?? null, name: row.user_name || namesById?.get(String(row.user_id)) || null,
assignedByName: row.assigned_by_name ?? null,
role: row.assignment_role || 'primary_recruiter', role: row.assignment_role || 'primary_recruiter',
jobPostId: row.job_post_id ?? null, jobPostId: row.job_post_id ?? null,
inboxId: row.inbox_id ?? null, inboxId: row.inbox_id ?? null,

View File

@ -13,6 +13,7 @@
============================================================ */ ============================================================ */
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient' import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
import { STATUS_FROM_STAGE } from './pipeline'
/** Active job posts for pickers. Needs job_board.view OR candidates.view. /** Active job posts for pickers. Needs job_board.view OR candidates.view.
* *
@ -92,6 +93,34 @@ export function viewCvBankCv(id) {
}) })
} }
/**
* Job Matching queue CV Import "No job" rows (`apply_via=cv_bank`).
* Needs candidates.view. `assigned` is tri-valued: omit for all, false for
* still in the bank, true for rows that already have a job_post_id.
*/
export function listMatching({ search, top = 10, skip = 0, assigned } = {}) {
return request('/candidate/matching/fetch', {
params: { search, top, skip, assigned },
})
}
/** One matching row by manual_upload_candidate id. Needs candidates.view. */
export function getMatching(id) {
return request('/candidate/matching/fetch_by_id', { params: { id } })
}
/**
* Link (or unlink) a job post on a CV-bank row. Needs candidates.edit.
* After assign the row has job_post_id + user_id and Pipeline/Candidates
* fetch it like any other manual_upload_candidate.
*/
export function assignMatchingJob(id, jobPostId) {
return request('/candidate/matching/assign', {
method: 'POST',
body: { id, job_post_id: jobPostId },
})
}
/** /**
* Score the decoded attachments of inbox messages against a job post. * Score the decoded attachments of inbox messages against a job post.
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id` * Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
@ -290,7 +319,7 @@ export function createManual({
put('current_position', currentPosition) put('current_position', currentPosition)
put('platform', source) put('platform', source)
put('experience', experience) put('experience', experience)
put('status', stage) put('status', STATUS_FROM_STAGE[stage] || stage)
put('referral_by', referralBy) put('referral_by', referralBy)
return request('/candidate/create/candidate', { method: 'POST', body: form }) return request('/candidate/create/candidate', { method: 'POST', body: form })
} }

View File

@ -20,7 +20,7 @@ export function listMessages() {
* `assigned` is tri-valued: omit for no filter, true for rows with an * `assigned` is tri-valued: omit for no filter, true for rows with an
* assigned_job_post_id, false for the Job Matching queue. * assigned_job_post_id, false for the Job Matching queue.
*/ */
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions } = {}) { export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState } = {}) {
return request('/inbox/all-applications', { return request('/inbox/all-applications', {
// `isread` is tri-valued on the wire: omit it for every tab (server defaults // `isread` is tri-valued on the wire: omit it for every tab (server defaults
// to true = no filter), send false for the Unread tab only. buildUrl drops // to true = no filter), send false for the Unread tab only. buildUrl drops
@ -30,6 +30,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
// Same for `is_duplicate`: omit unless the Duplicates tab. // Same for `is_duplicate`: omit unless the Duplicates tab.
// `no_suggestions`: Job Matching "No suggestions" tab — unassigned + empty // `no_suggestions`: Job Matching "No suggestions" tab — unassigned + empty
// suggested_job_post_ids. Omit unless that tab. // suggested_job_post_ids. Omit unless that tab.
// `processing_state`: Processed / Rejected tabs (Move to Shortlist writes
// processed, not application_status PROCESS).
params: { params: {
search, search,
top, top,
@ -40,6 +42,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
assigned, assigned,
is_duplicate: isDuplicate, is_duplicate: isDuplicate,
no_suggestions: noSuggestions, no_suggestions: noSuggestions,
processing_state: processingState,
}, },
}) })
} }
@ -122,7 +125,7 @@ export function bulkSetRead(recordIds, read) {
* Resolves to `{updated, read}`, where `updated` counts rows that actually * Resolves to `{updated, read}`, where `updated` counts rows that actually
* CHANGED state, so it is safe to show in a toast. * CHANGED state, so it is safe to show in a toast.
*/ */
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate } = {}) { export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, processingState } = {}) {
return request('/inbox/read-all', { return request('/inbox/read-all', {
method: 'PATCH', method: 'PATCH',
body: { body: {
@ -132,6 +135,7 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned,
application_status: applicationStatus, application_status: applicationStatus,
assigned, assigned,
is_duplicate: isDuplicate, is_duplicate: isDuplicate,
processing_state: processingState,
}, },
}) })
} }

View File

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

View File

@ -9,13 +9,14 @@ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
* picker payload does not. * picker payload does not.
*/ */
export function list({ search, department, requisitionStatus, employmentType, export function list({ search, department, requisitionStatus, employmentType,
top, skip, activeOnly } = {}) { hiringManagerId, top, skip, activeOnly } = {}) {
return request('/jobs/fetch', { return request('/jobs/fetch', {
params: { params: {
search, search,
department, department,
requisition_status: requisitionStatus, requisition_status: requisitionStatus,
employment_type: employmentType, employment_type: employmentType,
hiring_manager_id: hiringManagerId,
top, top,
skip, skip,
active_only: activeOnly, active_only: activeOnly,
@ -24,9 +25,22 @@ export function list({ search, department, requisitionStatus, employmentType,
} }
/* requisition_status is the HIRING lifecycle. The row's separate `status` field is /* requisition_status is the HIRING lifecycle. The row's separate `status` field is
the Buffer publishing lifecycle never map the two onto one badge. */ the Buffer publishing lifecycle never map the two onto one badge. Fallback
const REQ_STATUS_LABEL = { open: 'Open', closed: 'Closed', on_hold: 'On Hold' } matches GET /jobs/requisition-statuses/fetch so the dropdown still works if
export const JOB_STATUSES = Object.values(REQ_STATUS_LABEL) that call 403s. */
export const REQUISITION_STATUSES = [
{ value: 'open', label: 'Open' },
{ value: 'on_hold', label: 'On Hold' },
{ value: 'closed', label: 'Closed' },
{ value: 'completed', label: 'Completed' },
]
const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label]))
const LABEL_TO_STATUS = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.label, s.value]))
export const JOB_STATUSES = REQUISITION_STATUSES.map((s) => s.label)
export function listRequisitionStatuses() {
return request('/jobs/requisition-statuses/fetch')
}
function experienceLabel(min, max) { function experienceLabel(min, max) {
if (min == null && max == null) return null if (min == null && max == null) return null
@ -48,6 +62,8 @@ export function toJobView(row) {
publishStatus: row.status, publishStatus: row.status,
recruiter: row.recruiter_name, recruiter: row.recruiter_name,
recruiterId: row.current_recruiter_id, recruiterId: row.current_recruiter_id,
hiringManager: row.hiring_manager_name,
hiringManagerId: row.hiring_manager_id,
createdByName: row.created_by_name, createdByName: row.created_by_name,
applicantCount: row.applicant_count ?? 0, applicantCount: row.applicant_count ?? 0,
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working. // A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
@ -63,8 +79,6 @@ export function toJobView(row) {
} }
} }
const LABEL_TO_STATUS = { Open: 'open', Closed: 'closed', 'On Hold': 'on_hold' }
/** /**
* Styled .xlsx download of the requisition list GET /jobs/export * Styled .xlsx download of the requisition list GET /jobs/export
* (jobs.export). Same filters as list(); `status` takes the UI label. * (jobs.export). Same filters as list(); `status` takes the UI label.
@ -118,3 +132,8 @@ export function setStatus(jobPostId, status) {
body: { requisition_status }, body: { requisition_status },
}) })
} }
/** Status-change audit for one requisition — GET /jobs/status-history/fetch. */
export function listStatusHistory(jobPostId) {
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })
}

View File

@ -15,40 +15,43 @@ import { request } from '../lib/apiClient'
/** /**
* Candidate_application_Status (backend/inbox/enums.py) -> the board column. * Candidate_application_Status (backend/inbox/enums.py) -> the board column.
* *
* The enum has 11 values and the board 7 columns, so this is deliberately * The enum has 11 values. Approved and On Hold are first-class columns (they
* many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere) * used to be folded into Hired / Screening). CLOSED is the inbox default for
* and reads as Shortlist rather than as an outcome, ONHOLD parks in Screening, and * an application that did not progress it reads as Rejected, same as the
* APPROVED is the pre-HIRED spelling of a hire. * board's Rejected column, not as Shortlist.
* *
* Anything unmapped falls through to Shortlist rather than vanishing from the * Anything unmapped falls through to Shortlist rather than vanishing from the
* board a card with no column is a candidate nobody sees. * board a card with no column is a candidate nobody sees.
*/ */
export const STAGE_FROM_STATUS = { export const STAGE_FROM_STATUS = {
PENDING: 'Shortlist', PENDING: 'Shortlist',
CLOSED: 'Shortlist',
PROCESS: 'Screening', PROCESS: 'Screening',
ONHOLD: 'Screening',
SCREENING: 'Screening', SCREENING: 'Screening',
ONHOLD: 'On Hold',
ASSESSMENT: 'Assessment', ASSESSMENT: 'Assessment',
INTERVIEW: 'Interview', INTERVIEW: 'Interview',
OFFER: 'Offer', OFFER: 'Offer',
APPROVED: 'Approved',
HIRED: 'Hired', HIRED: 'Hired',
APPROVED: 'Hired', CLOSED: 'Rejected',
REJECTED: 'Rejected', REJECTED: 'Rejected',
} }
/** /**
* Column -> the status WRITTEN on a drop. Not the inverse of the map above: the * Column -> the status WRITTEN on a drop. Not the inverse of the map above:
* legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are * PROCESS is readable as Screening but is never written, so the vocabulary
* never written, so the vocabulary converges on the canonical value as cards get * converges on the canonical value as cards get moved. Shortlist writes
* moved. Shortlist writes PENDING because the enum has no SHORTLIST member. * PENDING because the enum has no SHORTLIST member. Rejected writes REJECTED
* (not CLOSED) so new drops are distinguishable from the inbox default.
*/ */
export const STATUS_FROM_STAGE = { export const STATUS_FROM_STAGE = {
Shortlist: 'PENDING', Shortlist: 'PENDING',
Screening: 'SCREENING', Screening: 'SCREENING',
'On Hold': 'ONHOLD',
Assessment: 'ASSESSMENT', Assessment: 'ASSESSMENT',
Interview: 'INTERVIEW', Interview: 'INTERVIEW',
Offer: 'OFFER', Offer: 'OFFER',
Approved: 'APPROVED',
Hired: 'HIRED', Hired: 'HIRED',
Rejected: 'REJECTED', Rejected: 'REJECTED',
} }
@ -84,7 +87,7 @@ export function listApplications({ jobId, limit, offset } = {}) {
} }
/** /**
* Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN) * Fold the 11 status counts into the board columns. Unmapped keys (UNKNOWN)
* land in Shortlist, same as STAGE_FROM_STATUS's card fallback. * land in Shortlist, same as STAGE_FROM_STATUS's card fallback.
*/ */
export function toStageCounts(byStatus) { export function toStageCounts(byStatus) {

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 } }) return request('/users/fetch', { params: { record_id, search, top, skip, role_id: roleId } })
} }
export function listManagers() {
return request('/managers/fetch')
}
export function listPendingApprovals() { export function listPendingApprovals() {
return request('/users/pending-approvals') return request('/users/pending-approvals')
} }

View File

@ -18,7 +18,7 @@ export const ROUTES = [
// --- Workspace --- // --- Workspace ---
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' }, { path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
{ path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' }, { path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' },
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'inbox.view', badge: 'matching' }, { path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' },
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' }, { path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' }, { path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' }, { path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },

View File

@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import * as inboxApi from '../api/inbox' import * as inboxApi from '../api/inbox'
import * as candidatesApi from '../api/candidates'
import * as tasksApi from '../api/tasks' import * as tasksApi from '../api/tasks'
import * as jobsApi from '../api/jobs' import * as jobsApi from '../api/jobs'
import * as notificationsApi from '../api/notifications' import * as notificationsApi from '../api/notifications'
@ -94,15 +95,19 @@ export function useHotkeys({ onEscape }) {
* that every mutating call site had to remember to call; these are derived, so * that every mutating call site had to remember to call; these are derived, so
* completing a task updates the badge with no call site involved at all. * completing a task updates the badge with no call site involved at all.
* *
* `matching` is the unassigned applications queue the one number Job Matching * `matching` is the unassigned CV-bank queue CVs imported with No job that
* exists to drive to zero. * still have no job_post_id. Job Matching exists to drive that number to zero.
*/ */
export function useBadges() { export function useBadges() {
const { data: matchingTotal = 0 } = useQuery({ const { data: matchingTotal = 0 } = useQuery({
queryKey: qk.mailbox.assignments({ assigned: false }), queryKey: qk.candidates.matching({ assigned: false, count: true }),
queryFn: async () => { queryFn: async () => {
const res = await inboxApi.listApplications({ assigned: false, top: 1 }) try {
const res = await candidatesApi.listMatching({ assigned: false, top: 1 })
return res?.total ?? 0 return res?.total ?? 0
} catch {
return 0
}
}, },
}) })
const { data: tasksTotal = 0 } = useQuery({ const { data: tasksTotal = 0 } = useQuery({

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 grades = ['L2', 'L3', 'L4', 'L5', 'L6', 'L7'];
const jobStatuses = ['Open', 'On Hold', 'Closed', 'Draft']; const jobStatuses = ['Open', 'On Hold', 'Closed', 'Draft'];
const educationLevels = ["Bachelor's Degree", "Master's Degree", "PhD", "Associate Degree", "High School"]; const educationLevels = ["Bachelor's Degree", "Master's Degree", "PhD", "Associate Degree", "High School"];
const stages = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected']; const stages = ['Shortlist', 'Screening', 'On Hold', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'Rejected'];
const sources = ['LinkedIn', 'Company Site', 'Referral', 'Indeed', 'Job Fair', 'Agency', 'GitHub', 'AngelList']; const sources = ['LinkedIn', 'Company Site', 'Referral', 'Indeed', 'Job Fair', 'Agency', 'GitHub', 'AngelList'];
const companies = ['Stripe', 'Airbnb', 'Datadog', 'Notion', 'Figma', 'Shopify', 'Snowflake', 'Twilio', 'Coinbase', 'Atlassian', 'Asana', 'Ramp', 'Brex', 'Vercel', 'Retool', 'Amplitude', 'Segment', 'MongoDB', 'HashiCorp', 'Cloudflare']; const companies = ['Stripe', 'Airbnb', 'Datadog', 'Notion', 'Figma', 'Shopify', 'Snowflake', 'Twilio', 'Coinbase', 'Atlassian', 'Asana', 'Ramp', 'Brex', 'Vercel', 'Retool', 'Amplitude', 'Segment', 'MongoDB', 'HashiCorp', 'Cloudflare'];

View File

@ -286,6 +286,7 @@ function css(name) { return getComputedStyle(document.documentElement).getProper
let a0 = -Math.PI / 2; let a0 = -Math.PI / 2;
segs.length = 0; segs.length = 0;
data.forEach((v, i) => { data.forEach((v, i) => {
if (!v) return
const a1 = a0 + (v / total) * Math.PI * 2 * prog; const a1 = a0 + (v / total) * Math.PI * 2 * prog;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.beginPath(); ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, a0, a1); ctx.closePath(); ctx.arc(cx, cy, r, a0, a1); ctx.closePath();

View File

@ -54,6 +54,7 @@ export const qk = {
managers: { managers: {
all: () => ['managers'], all: () => ['managers'],
list: (p = {}) => ['managers', 'list', p], list: (p = {}) => ['managers', 'list', p],
directory: () => ['managers', 'directory'],
}, },
orgSettings: { orgSettings: {
all: () => ['orgSettings'], all: () => ['orgSettings'],
@ -78,6 +79,8 @@ export const qk = {
jobs: { jobs: {
all: () => ['jobs'], all: () => ['jobs'],
list: (p = {}) => ['jobs', 'list', p], list: (p = {}) => ['jobs', 'list', p],
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
statusHistory: (id) => ['jobs', 'status-history', id],
}, },
talent: { talent: {
all: () => ['talent'], all: () => ['talent'],
@ -92,6 +95,8 @@ export const qk = {
count: (p = {}) => ['candidates', 'count', p], count: (p = {}) => ['candidates', 'count', p],
detail: (id) => ['candidates', 'detail', id], detail: (id) => ['candidates', 'detail', id],
history: (id, p = {}) => ['candidates', 'history', id, p], history: (id, p = {}) => ['candidates', 'history', id, p],
matching: (p = {}) => ['candidates', 'matching', p],
matchingDetail: (id) => ['candidates', 'matching', 'detail', id],
}, },
// Board rows come from the same endpoint as qk.candidates.list but are cached // Board rows come from the same endpoint as qk.candidates.list but are cached
// MAPPED (kanban cards, not the raw envelope), so they need their own key — // MAPPED (kanban cards, not the raw envelope), so they need their own key —

View File

@ -342,10 +342,10 @@ export default function Analytics() {
} }
}, [offersQuery.data]) }, [offersQuery.data])
/* The funnel has 11 statuses; REJECTED is dropped because it is an outcome, /* Fold the 11 enum statuses onto the 7 pipeline columns; REJECTED is dropped
not a stage, and its volume flattens every other bar. */ because it is an outcome, not a stage, and its volume flattens every other bar. */
const pipeline = useMemo(() => { const pipeline = useMemo(() => {
const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED') const rows = analyticsApi.toBoardStageRows(funnelQuery.data ?? [])
return { return {
labels: rows.map((p) => p.stage), labels: rows.map((p) => p.stage),
data: rows.map((p) => p.count), data: rows.map((p) => p.count),

View File

@ -44,7 +44,7 @@ import { companies, fmtDate, moneyK, pick } from '../data/seed'
Forms, Feedback) track (Notes, Activity) audit (Timeline, History). */ Forms, Feedback) track (Notes, Activity) audit (Timeline, History). */
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History'] const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History']
// Forward progression for the live Advance button. Rejected has no next stage. // Forward progression for the live Advance button. Rejected has no next stage.
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 } const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire'] const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round'] const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
@ -213,6 +213,7 @@ export default function CandidateProfile({
onDone: () => { onDone: () => {
qc.invalidateQueries({ queryKey: qk.pipeline.all() }) qc.invalidateQueries({ queryKey: qk.pipeline.all() })
qc.invalidateQueries({ queryKey: qk.forms.all() }) qc.invalidateQueries({ queryKey: qk.forms.all() })
qc.invalidateQueries({ queryKey: qk.analytics.all() })
}, },
}) })
@ -281,7 +282,7 @@ export default function CandidateProfile({
{advanceLive.isPending {advanceLive.isPending
? 'Moving…' ? 'Moving…'
: nextStage ? `Advance to ${nextStage}` : nextStage ? `Advance to ${nextStage}`
: stageLabel === 'Rejected' ? 'Rejected' : 'Pipeline complete'} : stageLabel === 'Rejected' || stageLabel === 'On Hold' ? stageLabel : 'Pipeline complete'}
</button> </button>
) : ( ) : (
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}> <button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>

View File

@ -32,7 +32,7 @@ import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '.
const EMPTY_FILTERS = { account: '', department: '' } const EMPTY_FILTERS = { account: '', department: '' }
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */ /** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
/** Seeded `candidate` role is id 8; id 4 is hiring_manager (signup default). */ /** Seeded `candidate` role is id 8; id 4 is hiring_manager (signup default). */
const CANDIDATE_ROLE_ID = 8 const CANDIDATE_ROLE_ID = 8

View File

@ -349,9 +349,8 @@ export default function CvImport() {
) )
} }
/* The stored-CV bank a private store with no job, account or inbox entry. /* The stored-CV bank CVs imported with No job. Unassigned rows live here;
This list is the bank's home: browse, download, or remove; picking a CV up assigning a job in Job Matching sets job_post_id and they leave this list. */
for a job later is a future action. */
function CvBank() { function CvBank() {
const { toast } = useToast() const { toast } = useToast()
const qc = useQueryClient() const qc = useQueryClient()
@ -432,6 +431,11 @@ function CvBank() {
{r.candidate_email || 'No email detected'} {r.candidate_email || 'No email detected'}
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''} {r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
</div> </div>
{r.linkedin_url ? (
<div className="cell-sub" style={{ marginTop: 2 }}>
<a href={r.linkedin_url} target="_blank" rel="noopener noreferrer">{r.linkedin_url}</a>
</div>
) : null}
{r.file_path ? ( {r.file_path ? (
<div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}> <div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}>
{r.file_path} {r.file_path}

View File

@ -15,9 +15,14 @@ import { fmtShort, money, initials as initialsOf, avatarColor } from '../data/se
import * as analyticsApi from '../api/analytics' import * as analyticsApi from '../api/analytics'
import * as interviewsApi from '../api/interviews' import * as interviewsApi from '../api/interviews'
import * as jobsApi from '../api/jobs' import * as jobsApi from '../api/jobs'
import * as pipelineApi from '../api/pipeline'
import * as tasksApi from '../api/tasks' import * as tasksApi from '../api/tasks'
const POLL_MS = 60_000 const POLL_MS = 60_000
const BOARD_ACTIVE = [
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
'Approved', 'Hired', 'On Hold', 'Rejected',
]
function asObject(data) { function asObject(data) {
return data && typeof data === 'object' && !Array.isArray(data) ? data : null return data && typeof data === 'object' && !Array.isArray(data) ? data : null
@ -56,17 +61,6 @@ function trendProps(cur, prior, { lowerIsBetter = false, fmt = pctDelta } = {})
return { trend: text, dir: good ? 'up' : 'down', arrow: went } return { trend: text, dir: good ? 'up' : 'down', arrow: went }
} }
/* Display order for pipeline stages: progression first, then held/terminal.
The API returns enum order, which interleaves them (PROCESS before PENDING,
CLOSED before SCREENING). */
const STAGE_ORDER = [
'PENDING', 'SCREENING', 'PROCESS', 'ASSESSMENT', 'INTERVIEW',
'OFFER', 'APPROVED', 'HIRED', 'ONHOLD', 'CLOSED',
]
const stageRank = (s) => {
const i = STAGE_ORDER.indexOf(s)
return i === -1 ? STAGE_ORDER.length : i
}
function greetingFor(now = new Date()) { function greetingFor(now = new Date()) {
const h = now.getHours() const h = now.getHours()
@ -200,9 +194,18 @@ export default function Dashboard() {
}, },
refetchInterval: POLL_MS, refetchInterval: POLL_MS,
}) })
/* Same counts the /pipeline board paints not /analytics/funnel. Funnel is a
second query with a 60s staleTime, so a drag to Screening left this widget
on the previous snapshot (Interview still filled the doughnut). The board
already invalidates qk.pipeline.all() on drop. staleTime 0 so navigating
back here never serves that snapshot as "fresh". */
const funnelQuery = useQuery({ const funnelQuery = useQuery({
queryKey: qk.analytics.funnel(), queryKey: qk.pipeline.board({ scope: 'dashboard' }),
queryFn: async () => asList((await analyticsApi.funnel()).data), queryFn: async () => {
const res = await pipelineApi.listApplications({ limit: 1 })
return pipelineApi.toStageCounts(res?.counts?.by_status)
},
staleTime: 0,
refetchInterval: POLL_MS, refetchInterval: POLL_MS,
}) })
@ -278,21 +281,20 @@ export default function Dashboard() {
[trendQuery.data], [trendQuery.data],
) )
/* "Active by stage" means exactly that: REJECTED is excluded (matching the /* Occupied board columns only. Zero-count stages in the doughnut are a
Analytics screen's pipeline card), and each bar is that stage's share of canvas footgun (arc(a,a) can paint a full circle and hide Screening). */
the ACTIVE total the old base was the first row's count, which is the
PROCESS stage in enum order, so an empty PROCESS stage zeroed every bar
while the doughnut centre said candidates existed. */
const pipeRows = useMemo(() => { const pipeRows = useMemo(() => {
const rows = asList(funnelQuery.data) const folded = funnelQuery.data && typeof funnelQuery.data === 'object'
.filter((r) => r.stage !== 'REJECTED') ? funnelQuery.data
.sort((a, b) => stageRank(a.stage) - stageRank(b.stage)) : {}
const total = rows.reduce((sum, r) => sum + (r.count || 0), 0) const rows = BOARD_ACTIVE
.map((stage) => ({ stage, count: folded[stage] || 0 }))
.filter((r) => r.count > 0)
const total = rows.reduce((sum, r) => sum + r.count, 0)
const pal = Charts.PALETTE const pal = Charts.PALETTE
return rows.map((r, i) => ({ return rows.map((r, i) => ({
stage: r.stage, ...r,
count: r.count, pct: total ? Math.round((r.count / total) * 100) : 0,
pct: total ? Math.round(((r.count || 0) / total) * 100) : 0,
color: pal[i % pal.length], color: pal[i % pal.length],
})) }))
}, [funnelQuery.data]) }, [funnelQuery.data])
@ -300,8 +302,8 @@ export default function Dashboard() {
const pipelineDoughnut = useMemo(() => ({ const pipelineDoughnut = useMemo(() => ({
labels: pipeRows.map((p) => p.stage), labels: pipeRows.map((p) => p.stage),
data: pipeRows.map((p) => p.count), data: pipeRows.map((p) => p.count),
colors: Charts.PALETTE, colors: pipeRows.map((p) => p.color),
centerValue: pipeRows.reduce((sum, s) => sum + (s.count || 0), 0), centerValue: pipeRows.reduce((sum, s) => sum + s.count, 0),
centerLabel: 'In pipeline', centerLabel: 'In pipeline',
}), [pipeRows]) }), [pipeRows])
@ -453,16 +455,16 @@ export default function Dashboard() {
<div> <div>
<h3>Candidate Pipeline</h3> <h3>Candidate Pipeline</h3>
<span className="ch-sub"> <span className="ch-sub">
{funnelQuery.isPending ? 'Loading…' : 'Active by stage, rejections excluded'} {funnelQuery.isPending ? 'Loading…' : 'Active by stage'}
</span> </span>
</div> </div>
</div> </div>
<div className="card-body"> <div className="card-body">
{funnelQuery.isError ? ( {funnelQuery.isError ? (
<EmptyState icon="alert" title="Couldnt load pipeline"> <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> </EmptyState>
) : pipeRows.length === 0 && funnelQuery.isSuccess ? ( ) : funnelQuery.isSuccess && pipeRows.length === 0 ? (
<EmptyState icon="inbox" title="No pipeline data yet"> <EmptyState icon="inbox" title="No pipeline data yet">
Stage counts appear once applications are in the system. Stage counts appear once applications are in the system.
</EmptyState> </EmptyState>
@ -478,7 +480,7 @@ export default function Dashboard() {
style={{ width: `${r.pct}%`, background: r.color }} style={{ width: `${r.pct}%`, background: r.color }}
/> />
</div> </div>
<span className="pipe-pct">{r.pct}%</span> <span className="pipe-pct" title={`${r.pct}%`}>{r.count}</span>
</div> </div>
))} ))}
</div> </div>

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' } const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet' }
/** /**
* Server-side filters for each tab. Email Processed / Rejected use * Server-side filters for each tab. Email Processed / Rejected follow
* Candidate_application_Status (PROCESS / REJECTED). Sheet Forms use * processing_state (the same writes as Import / Shortlist / Reject). Sheet
* form_data.processing_state (same vocabulary as inbox Import/Reject). * Forms use form_data.processing_state.
*/ */
const TAB_FILTERS = { const TAB_FILTERS = {
Unread: { isread: false }, Unread: { isread: false },
Processed: { applicationStatus: 'PROCESS' }, Processed: { processingState: 'processed' },
Rejected: { applicationStatus: 'REJECTED' }, Rejected: { processingState: 'rejected' },
Duplicates: { isDuplicate: true }, Duplicates: { isDuplicate: true },
} }
@ -224,9 +224,9 @@ function SourceChip({ item }) {
// The dot carries the partner's brand colour; the label uses theme text // The dot carries the partner's brand colour; the label uses theme text
// 11px labels in the partner colour failed AA in both themes. // 11px labels in the partner colour failed AA in both themes.
return ( return (
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }}> <span className="source-chip" style={{ '--chip': item.sourceMeta?.color }} title={item.source}>
<span className="source-dot" /> <span className="source-dot" />
{item.source} <span className="source-chip-label">{item.source}</span>
</span> </span>
) )
} }
@ -904,6 +904,7 @@ export default function Inbox() {
qc.invalidateQueries({ queryKey: qk.mailbox.counts() }) qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
qc.invalidateQueries({ queryKey: qk.pipeline.all() }) qc.invalidateQueries({ queryKey: qk.pipeline.all() })
qc.invalidateQueries({ queryKey: qk.candidates.all() }) qc.invalidateQueries({ queryKey: qk.candidates.all() })
qc.invalidateQueries({ queryKey: qk.analytics.all() })
}, },
}) })
@ -1104,17 +1105,7 @@ export default function Inbox() {
)} )}
</div> </div>
<div className="ii-pos">{i.position}</div> <div className="ii-pos">{i.position}</div>
<div className="ii-meta"> <div className="ii-aside">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{i.applicationStatus}</Badge>
)}
{isForms && i.residingCity && (
<span className="cell-sub">{i.residingCity}</span>
)}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="ii-time"> <div className="ii-time">
{outlookListTime(i.received)} {outlookListTime(i.received)}
</div> </div>
@ -1125,6 +1116,16 @@ export default function Inbox() {
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div> <div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
)} )}
</div> </div>
<div className="ii-meta">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{i.applicationStatus}</Badge>
)}
{isForms && i.residingCity && (
<span className="cell-sub">{i.residingCity}</span>
)}
</div>
</div>
</div> </div>
)) ))
)} )}
@ -1310,6 +1311,7 @@ function FormApplicantDetail({
qc.invalidateQueries({ queryKey: qk.mailbox.formRow(vars.recordId) }) qc.invalidateQueries({ queryKey: qk.mailbox.formRow(vars.recordId) })
qc.invalidateQueries({ queryKey: qk.pipeline.all() }) qc.invalidateQueries({ queryKey: qk.pipeline.all() })
qc.invalidateQueries({ queryKey: qk.candidates.all() }) qc.invalidateQueries({ queryKey: qk.candidates.all() })
qc.invalidateQueries({ queryKey: qk.analytics.all() })
}, },
}) })
@ -1648,6 +1650,8 @@ function ApplicationDetail({
onSettled: (_res, _err, vars) => { onSettled: (_res, _err, vars) => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() }) qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) }) qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
qc.invalidateQueries({ queryKey: qk.candidates.all() })
}, },
}) })

View File

@ -15,6 +15,7 @@ import AiFieldAssist from '../ui/AiFieldAssist'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs'
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives' import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
@ -26,7 +27,7 @@ import * as jobsApi from '../api/jobs'
import * as jobPostsApi from '../api/jobPosts' import * as jobPostsApi from '../api/jobPosts'
import * as assignmentsApi from '../api/assignments' import * as assignmentsApi from '../api/assignments'
import * as tasksApi from '../api/tasks' import * as tasksApi from '../api/tasks'
import { JOB_STATUSES } from '../api/jobs' import * as usersApi from '../api/users'
import { empTypes, fmtShort } from '../data/seed' import { empTypes, fmtShort } from '../data/seed'
// Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list). // Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list).
@ -76,6 +77,18 @@ export default function Jobs() {
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data]) const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data])
const statusesQuery = useQuery({
queryKey: qk.jobs.requisitionStatuses(),
queryFn: async () => {
const res = await jobsApi.listRequisitionStatuses()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.length ? rows : jobsApi.REQUISITION_STATUSES
},
})
const statusLabels = useMemo(
() => (statusesQuery.data ?? jobsApi.REQUISITION_STATUSES).map((s) => s.label),
[statusesQuery.data],
)
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [dept, setDept] = useState('') const [dept, setDept] = useState('')
@ -90,12 +103,19 @@ export default function Jobs() {
const canDelete = can('jobs.delete') const canDelete = can('jobs.delete')
// Deep-link intents from global search, the dashboard and the manager portal. // Deep-link intents from global search, the dashboard and the manager portal.
// Consume once and replace history state: jobs refetch after a status PATCH
// used to replay openCreate and pop the create modal over the detail view.
useEffect(() => { useEffect(() => {
const st = location.state const st = location.state
if (!st) return if (!st?.openCreate && !st?.openJob) return
if (st.openCreate) setCreating(true) if (st.openCreate) setCreating(true)
if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null) if (st.openJob) {
}, [location.state, jobs]) const job = jobs.find((j) => j.id === st.openJob)
if (job) setViewing(job)
else if (!jobsQuery.isSuccess) return
}
navigate('.', { replace: true, state: null })
}, [location.state, jobs, jobsQuery.isSuccess, navigate])
useEffect(() => { useEffect(() => {
if (!viewing) return if (!viewing) return
@ -183,7 +203,7 @@ export default function Jobs() {
if (type && j.type !== type) return false if (type && j.type !== type) return false
if (q) { if (q) {
const term = q.toLowerCase() const term = q.toLowerCase()
const hay = [j.title, j.department, j.recruiter, j.location] const hay = [j.title, j.department, j.recruiter, j.hiringManager, j.location]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
@ -226,6 +246,8 @@ export default function Jobs() {
{ key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ? <Badge className="b-gray">{platformLabel(j.platform)}</Badge> : '—' }, { key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ? <Badge className="b-gray">{platformLabel(j.platform)}</Badge> : '—' },
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> }, { key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> },
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> }, { key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
{ key: 'hiringManager', label: 'Hiring Manager', sortable: true, render: (j) => j.hiringManager || '—' },
{ key: 'recruiter', label: 'Recruiter', sortable: true, render: (j) => j.recruiter || '—' },
{ {
key: 'created', label: 'Created', sortable: true, key: 'created', label: 'Created', sortable: true,
sortValue: (j) => (j.created ? j.created.getTime() : 0), sortValue: (j) => (j.created ? j.created.getTime() : 0),
@ -235,17 +257,17 @@ export default function Jobs() {
key: '_a', label: 'Actions', align: 'right', key: '_a', label: 'Actions', align: 'right',
render: (j) => ( render: (j) => (
<div className="row-actions"> <div className="row-actions">
<button className="act-btn" data-tip="View" aria-label="View job" onClick={() => setViewing(j)}><Icon name="eye" /></button> <button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); setViewing(j) }}><Icon name="eye" /></button>
{canEdit && ( {canEdit && (
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={() => setEditing(j)}><Icon name="edit" /></button> <button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button>
)} )}
{canEdit && (j.status === 'Closed' ? ( {canEdit && (j.status === 'Closed' || j.status === 'Completed' ? (
<button <button
className="act-btn" className="act-btn"
data-tip="Reopen" data-tip="Reopen"
aria-label="Reopen job" aria-label="Reopen job"
disabled={setJobStatus.isPending} disabled={setJobStatus.isPending}
onClick={() => setJobStatus.mutate({ id: j.id, status: 'Open' })} onClick={(e) => { e.stopPropagation(); setJobStatus.mutate({ id: j.id, status: 'Open' }) }}
><Icon name="refresh" /></button> ><Icon name="refresh" /></button>
) : ( ) : (
<button <button
@ -253,14 +275,15 @@ export default function Jobs() {
data-tip="Close job" data-tip="Close job"
aria-label="Close job" aria-label="Close job"
disabled={setJobStatus.isPending} disabled={setJobStatus.isPending}
onClick={() => { onClick={(e) => {
e.stopPropagation()
if (window.confirm(`Close “${j.title}”? It stays on the board and can be reopened later.`)) { if (window.confirm(`Close “${j.title}”? It stays on the board and can be reopened later.`)) {
setJobStatus.mutate({ id: j.id, status: 'Closed' }) setJobStatus.mutate({ id: j.id, status: 'Closed' })
} }
}} }}
><Icon name="x-circle" /></button> ><Icon name="x-circle" /></button>
))} ))}
<button className="act-btn" data-tip="Publish" aria-label="Publish job" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button> <button className="act-btn" data-tip="Publish" aria-label="Publish job" onClick={(e) => { e.stopPropagation(); navigate('/jobboard', { state: { publishJob: j.id } }) }}><Icon name="send" /></button>
</div> </div>
), ),
}, },
@ -310,7 +333,7 @@ export default function Jobs() {
</select> </select>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}> <select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option> <option value="">All Status</option>
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)} {statusLabels.map((s) => <option key={s}>{s}</option>)}
</select> </select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}> <select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option> <option value="">All Types</option>
@ -323,6 +346,7 @@ export default function Jobs() {
rows={rows} rows={rows}
pageSize={8} pageSize={8}
empty="No requisitions match these filters." empty="No requisitions match these filters."
onRowClick={(j) => setViewing(j)}
/> />
</> </>
)} )}
@ -339,6 +363,7 @@ export default function Jobs() {
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }} onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
onEdit={() => { setEditing(viewing); setViewing(null) }} onEdit={() => { setEditing(viewing); setViewing(null) }}
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })} onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
statusLabels={statusLabels}
onDelete={() => { onDelete={() => {
if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id) if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id)
}} }}
@ -374,9 +399,120 @@ const SECTION_LABEL = {
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/jpg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif' const IMAGE_ACCEPT = 'image/png,image/jpeg,image/jpg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif'
const MAX_IMAGE_MB = 5 const MAX_IMAGE_MB = 5
const ROLE_LABEL = { primary_recruiter: 'Recruiter', hiring_manager: 'Hiring manager' }
function fmtWhen(value) {
const d = value instanceof Date ? value : new Date(value ?? NaN)
if (Number.isNaN(d.getTime())) return null
const clock = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
return `${fmtShort(d)} · ${clock}`
}
/**
* Searchable picker: type to filter, click a row to store the id.
* Not free-text the value is always an option id (or '' when allowEmpty).
*/
function SearchSelect({
options = [],
value,
onChange,
placeholder = 'Search…',
disabled = false,
loading = false,
allowEmpty = false,
emptyLabel = 'Unassigned',
error = false,
}) {
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const root = useRef(null)
const selected = options.find((o) => String(o.id) === String(value || ''))
useEffect(() => {
function onDoc(e) {
if (root.current && !root.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
const term = q.trim().toLowerCase()
const filtered = options.filter((o) => {
if (!term) return true
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
return hay.includes(term)
})
return (
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
<input
className={error ? 'err' : ''}
value={open ? q : (selected?.name || '')}
disabled={disabled || loading}
placeholder={loading ? 'Loading…' : placeholder}
autoComplete="off"
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
/>
{open && !disabled && !loading && (
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
{allowEmpty && (
<button
type="button"
className="dropdown-link"
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
>
{emptyLabel}
</button>
)}
{filtered.length === 0 && (
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
)}
{filtered.map((o) => (
<button
type="button"
key={o.id}
className="dropdown-link"
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
>
{o.name}
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
</button>
))}
</div>
)}
</div>
)
}
function useManagerDirectory() {
return useQuery({
queryKey: qk.managers.directory(),
queryFn: async () => {
const res = await usersApi.listManagers()
return Array.isArray(res?.data) ? res.data : []
},
retry: false,
})
}
function useRecruiterDirectory() {
return useQuery({
queryKey: qk.tasks.assignees(),
queryFn: async () => {
const res = await tasksApi.listAssignees()
return Array.isArray(res?.data) ? res.data : []
},
retry: false,
})
}
function JobForm({ departmentOptions, busy, onClose, onSubmit }) { function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const form = useFormState({ const form = useFormState({
hiring_manager_id: '',
current_recruiter_id: '',
title: '', title: '',
department: '', department: '',
location: '', location: '',
@ -432,6 +568,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
const v = form.values const v = form.values
const errors = {} const errors = {}
if (!v.title.trim()) errors.title = 'Job title is required' if (!v.title.trim()) errors.title = 'Job title is required'
if (!v.hiring_manager_id) errors.hiring_manager_id = 'Hiring manager is required'
const vacancies = Number(v.vacancies) const vacancies = Number(v.vacancies)
if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1' if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1'
const expMin = v.experience_min === '' ? null : Number(v.experience_min) const expMin = v.experience_min === '' ? null : Number(v.experience_min)
@ -462,6 +599,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
requirements: splitLines(v.requirements), requirements: splitLines(v.requirements),
optional_skills: splitLines(v.optional_skills), optional_skills: splitLines(v.optional_skills),
description: v.description.trim() || null, description: v.description.trim() || null,
hiring_manager_id: v.hiring_manager_id,
current_recruiter_id: v.current_recruiter_id || undefined,
}, imageFile) }, imageFile)
} }
@ -522,6 +661,39 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
<FieldError>{form.errors.title}</FieldError> <FieldError>{form.errors.title}</FieldError>
</div> </div>
<div className="form-field">
<label>Hiring manager <span className="req">*</span></label>
<SearchSelect
options={managersQuery.data ?? []}
value={form.values.hiring_manager_id}
onChange={(id) => form.setField('hiring_manager_id', id)}
placeholder="Search hiring managers…"
disabled={busy}
loading={managersQuery.isPending}
error={Boolean(form.errors.hiring_manager_id)}
/>
<FieldError>{form.errors.hiring_manager_id}</FieldError>
{managersQuery.isError && (
<p className="text-muted text-sm">Could not load hiring managers.</p>
)}
</div>
<div className="form-field">
<label>Recruiter</label>
<SearchSelect
options={recruitersQuery.data ?? []}
value={form.values.current_recruiter_id}
onChange={(id) => form.setField('current_recruiter_id', id)}
placeholder="Search recruiters…"
disabled={busy}
loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
{recruitersQuery.isError && (
<p className="text-muted text-sm">Recruiter list needs tasks.view you can assign later.</p>
)}
</div>
<div className="form-field"> <div className="form-field">
<div className="field-label-row"> <div className="field-label-row">
<label>Department</label> <label>Department</label>
@ -671,6 +843,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
} }
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) { function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const form = useFormState({ const form = useFormState({
title: j.title || '', title: j.title || '',
department: j.department || '', department: j.department || '',
@ -680,6 +854,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
experience_min: j.experienceMin != null ? String(j.experienceMin) : '', experience_min: j.experienceMin != null ? String(j.experienceMin) : '',
experience_max: j.experienceMax != null ? String(j.experienceMax) : '', experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
description: j.description || '', description: j.description || '',
hiring_manager_id: j.hiringManagerId || '',
current_recruiter_id: j.recruiterId || '',
}) })
const assistContext = () => ({ const assistContext = () => ({
@ -707,10 +883,11 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
function submit() { function submit() {
if (busy) return if (busy) return
const title = form.values.title.trim() const title = form.values.title.trim()
if (!title) { const errors = {}
form.setErrors({ title: 'Job title is required' }) if (!title) errors.title = 'Job title is required'
return if (!form.values.hiring_manager_id) errors.hiring_manager_id = 'Hiring manager is required'
} form.setErrors(errors)
if (Object.keys(errors).length) return
onSubmit({ onSubmit({
title, title,
department: form.values.department.trim() || null, department: form.values.department.trim() || null,
@ -720,6 +897,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min), experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min),
experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max), experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
description: form.values.description.trim() || null, description: form.values.description.trim() || null,
hiring_manager_id: form.values.hiring_manager_id,
current_recruiter_id: form.values.current_recruiter_id || null,
}) })
} }
@ -748,6 +927,32 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
<input className={form.errors.title ? 'err' : ''} value={form.values.title} onChange={(e) => form.setField('title', e.target.value)} disabled={busy} /> <input className={form.errors.title ? 'err' : ''} value={form.values.title} onChange={(e) => form.setField('title', e.target.value)} disabled={busy} />
<FieldError>{form.errors.title}</FieldError> <FieldError>{form.errors.title}</FieldError>
</div> </div>
<div className="form-field">
<label>Hiring manager <span className="req">*</span></label>
<SearchSelect
options={managersQuery.data ?? []}
value={form.values.hiring_manager_id}
onChange={(id) => form.setField('hiring_manager_id', id)}
placeholder="Search hiring managers…"
disabled={busy}
loading={managersQuery.isPending}
error={Boolean(form.errors.hiring_manager_id)}
/>
<FieldError>{form.errors.hiring_manager_id}</FieldError>
</div>
<div className="form-field">
<label>Recruiter</label>
<SearchSelect
options={recruitersQuery.data ?? []}
value={form.values.current_recruiter_id}
onChange={(id) => form.setField('current_recruiter_id', id)}
placeholder="Search recruiters…"
disabled={busy}
loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
</div>
<div className="form-field"> <div className="form-field">
<div className="field-label-row"> <div className="field-label-row">
<label>Department</label> <label>Department</label>
@ -796,122 +1001,170 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
} }
/** /**
* Recruiter ownership of one requisition GET/POST /job/assignments/*. * Hiring-manager + recruiter pointers on one requisition.
* * Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
* Rows are valid-time intervals and the fetch returns only the OPEN one, so
* "the assigned recruiter" is simply the first row back. There is no unassign
* route: posting a new assignment closes the previous interval, which is why
* the control is a picker with a Save rather than an assign/remove pair.
*
* The picker is /tasks/assignees/fetch because the server rejects any
* non-recruiter with a 422, and that endpoint returns exactly the active
* recruiter-role users without needing rbac_users.view.
*/ */
function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) { function JobOwnership({ job, canEdit }) {
const { toast } = useToast() const { toast } = useToast()
const qc = useQueryClient() const qc = useQueryClient()
const [picked, setPicked] = useState('') const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const assigneesQuery = useQuery({ const patch = useMutation({
queryKey: qk.tasks.assignees(), mutationFn: (body) => jobsApi.update(job.id, body),
queryFn: async () => {
const res = await tasksApi.listAssignees()
return Array.isArray(res?.data) ? res.data : []
},
retry: false,
})
const namesById = useMemo(() => {
const map = new Map()
for (const u of assigneesQuery.data ?? []) map.set(String(u.id), u.name)
return map
}, [assigneesQuery.data])
const currentQuery = useQuery({
queryKey: qk.assignments.job(jobPostId),
queryFn: async () => {
const res = await assignmentsApi.listJob(jobPostId)
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((r) => assignmentsApi.toAssignmentView(r, namesById))
},
enabled: Boolean(jobPostId),
retry: false,
})
const current = currentQuery.data?.[0] ?? null
const assign = useMutation({
mutationFn: (userId) => assignmentsApi.assignJob({ jobPostId, userId }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.assignments.job(jobPostId) }) qc.invalidateQueries({ queryKey: qk.assignments.job(job.id) })
qc.invalidateQueries({ queryKey: qk.jobs.all() }) qc.invalidateQueries({ queryKey: qk.jobs.all() })
setPicked('') qc.invalidateQueries({ queryKey: qk.managers.all() })
toast('Recruiter assigned', 'success') toast('Assignment updated', 'success')
}, },
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the recruiter.'), 'error'), onError: (err) => toast(friendlyAuthError(err, 'Could not update the assignment.'), 'error'),
}) })
/* current.name resolves only once the assignee list has loaded; the
requisition's own recruiter_name is the fallback until then. */
const currentName = current?.name
|| (current ? namesById.get(String(current.userId)) : null)
|| fallbackName
|| null
return ( return (
<> <>
<div className="divider" /> <div className="divider" />
<div className="mb-16"> <div className="mb-16">
<div style={SECTION_LABEL}>Recruiter ownership</div> <div style={SECTION_LABEL}>Ownership</div>
{currentQuery.isError ? ( <div className="form-grid" style={{ marginBottom: 12 }}>
<p className="text-muted text-sm"> <div className="form-field">
{friendlyAuthError(currentQuery.error, 'Assignments did not load.')} <label>Hiring manager</label>
{' '}Needs the <code>jobs.view</code> permission. {canEdit ? (
</p> <SearchSelect
options={managersQuery.data ?? []}
value={job.hiringManagerId || ''}
onChange={(id) => {
if (!id || id === String(job.hiringManagerId || '')) return
patch.mutate({ hiring_manager_id: id })
}}
placeholder="Search hiring managers…"
disabled={patch.isPending}
loading={managersQuery.isPending}
/>
) : ( ) : (
<p className="text-muted text-sm" style={{ marginBottom: canEdit ? 10 : 0 }}> <p className="text-muted text-sm">{job.hiringManager || '—'}</p>
{currentQuery.isPending
? 'Loading…'
: currentName
? <>Owned by <b>{currentName}</b>{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''}</>
: 'No recruiter assigned yet.'}
</p>
)} )}
{canEdit && !currentQuery.isError && (
<div className="flex items-center gap-8">
<select
className="select"
value={picked}
disabled={assigneesQuery.isPending || assign.isPending}
onChange={(e) => setPicked(e.target.value)}
>
<option value="">
{assigneesQuery.isPending ? 'Loading recruiters…' : 'Assign a recruiter…'}
</option>
{(assigneesQuery.data ?? []).map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
<button
className="btn btn-secondary btn-sm"
disabled={!picked || assign.isPending}
onClick={() => assign.mutate(picked)}
>
{assign.isPending ? 'Assigning…' : 'Assign'}
</button>
</div> </div>
<div className="form-field">
<label>Recruiter</label>
{canEdit ? (
<SearchSelect
options={recruitersQuery.data ?? []}
value={job.recruiterId || ''}
onChange={(id) => {
const next = id || null
if (String(next || '') === String(job.recruiterId || '')) return
patch.mutate({ current_recruiter_id: next })
}}
placeholder="Search recruiters…"
disabled={patch.isPending}
loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
) : (
<p className="text-muted text-sm">{job.recruiter || 'No recruiter assigned yet.'}</p>
)} )}
{canEdit && assigneesQuery.isError && ( </div>
<p className="text-muted text-sm"> </div>
The recruiter list needs the <code>tasks.view</code> permission. {canEdit && recruitersQuery.isError && (
</p> <p className="text-muted text-sm">The recruiter list needs the <code>tasks.view</code> permission.</p>
)} )}
</div> </div>
</> </>
) )
} }
function JobHistory({ historyQuery, statusQuery }) {
const assignments = historyQuery.data ?? []
const statusRows = statusQuery.data ?? []
if (historyQuery.isError && statusQuery.isError) {
return (
<p className="text-muted text-sm">
{friendlyAuthError(historyQuery.error, 'History did not load.')}
</p>
)
}
if ((historyQuery.isPending && !historyQuery.data) || (statusQuery.isPending && !statusQuery.data)) {
return <p className="text-muted text-sm">Loading</p>
}
const events = [
...assignments.map((row) => ({
kind: 'assignment',
id: `a-${row.id}`,
at: row.validFrom,
row,
})),
...statusRows.map((row) => ({
kind: 'status',
id: `s-${row.id}`,
at: row.created_at ? new Date(row.created_at) : null,
row,
})),
].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0))
if (events.length === 0) {
return <p className="text-muted">No history yet.</p>
}
return (
<div className="list-tight">
{events.map((ev) => (
ev.kind === 'status'
? <StatusHistoryRow key={ev.id} row={ev.row} at={ev.at} />
: <AssignmentHistoryRow key={ev.id} row={ev.row} />
))}
</div>
)
}
function StatusHistoryRow({ row, at }) {
const fromLabel = row.from_label || row.from_status
const toLabel = row.to_label || row.to_status || '—'
const actor = row.changed_by_name || (row.changed_by ? 'Unknown' : 'System')
const title = fromLabel ? 'Status changed' : `Opened as ${toLabel}`
const change = fromLabel ? `${fromLabel}${toLabel}` : null
return (
<div className="list-row">
<span className="kpi-icn i-indigo" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="refresh" />
</span>
<div className="lr-main">
<div className="lr-title">{title}</div>
{change && <div className="lr-sub">{change}</div>}
<div className="lr-sub">
{[actor, at ? fmtWhen(at) : null].filter(Boolean).join(' · ')}
</div>
</div>
<Badge>{toLabel}</Badge>
</div>
)
}
function AssignmentHistoryRow({ row }) {
return (
<div className="list-row">
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="user" />
</span>
<div className="lr-main">
<div className="lr-title">{row.name || 'Unassigned'}</div>
<div className="lr-sub">
{[
ROLE_LABEL[row.role] || row.role?.replace(/_/g, ' '),
row.validFrom ? fmtShort(row.validFrom) : null,
row.validTo ? `${fmtShort(row.validTo)}` : 'current',
row.assignedByName ? `by ${row.assignedByName}` : null,
].filter(Boolean).join(' · ')}
</div>
</div>
{!row.validTo && <Badge className="b-green">Current</Badge>}
</div>
)
}
/* Cover image, when the post has one fetched with the bearer token into an /* Cover image, when the post has one fetched with the bearer token into an
object URL, because a bare <img src> cannot carry auth headers. null (404) object URL, because a bare <img src> cannot carry auth headers. null (404)
simply renders nothing. */ simply renders nothing. */
@ -935,7 +1188,30 @@ function JobCover({ jobId }) {
function JobDetail({ function JobDetail({
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete, job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
statusLabels = jobsApi.JOB_STATUSES,
}) { }) {
const [tab, setTab] = useState('details')
const historyQuery = useQuery({
queryKey: qk.assignments.job(j.id),
queryFn: async () => {
const res = await assignmentsApi.listJob(j.id, { currentOnly: false })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((r) => assignmentsApi.toAssignmentView(r))
},
enabled: Boolean(j.id),
retry: false,
})
const statusQuery = useQuery({
queryKey: qk.jobs.statusHistory(j.id),
queryFn: async () => {
const res = await jobsApi.listStatusHistory(j.id)
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(j.id),
retry: false,
})
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0)
return ( return (
<Modal <Modal
title="Job Details" title="Job Details"
@ -975,7 +1251,7 @@ function JobDetail({
disabled={statusBusy} disabled={statusBusy}
onChange={(e) => onStatus(e.target.value)} onChange={(e) => onStatus(e.target.value)}
> >
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)} {statusLabels.map((s) => <option key={s}>{s}</option>)}
</select> </select>
) : ( ) : (
<Badge>{j.status}</Badge> <Badge>{j.status}</Badge>
@ -983,6 +1259,17 @@ function JobDetail({
</div> </div>
</div> </div>
<Tabs
value={tab}
onChange={setTab}
tabs={[
{ key: 'details', label: 'Details' },
{ key: 'history', label: 'History', count: historyCount || undefined },
]}
/>
{tab === 'details' && (
<>
<div className="info-grid mb-18"> <div className="info-grid mb-18">
<div className="info-item"><div className="il">Department</div><div className="iv">{j.department || '—'}</div></div> <div className="info-item"><div className="il">Department</div><div className="iv">{j.department || '—'}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div> <div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
@ -992,11 +1279,12 @@ function JobDetail({
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div> <div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div> <div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div> <div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div> <div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div>
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div> <div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
</div> </div>
<RecruiterAssignment jobPostId={j.id} fallbackName={j.recruiter} canEdit={canEdit} /> <JobOwnership job={j} canEdit={canEdit} />
{j.description && ( {j.description && (
<> <>
@ -1013,6 +1301,10 @@ function JobDetail({
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div> <div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div> </div>
)} )}
</>
)}
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} />}
</Modal> </Modal>
) )
} }

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 { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
@ -53,6 +53,16 @@ export default function Managers() {
const managers = managersQuery.data?.rows ?? [] const managers = managersQuery.data?.rows ?? []
const total = managersQuery.data?.total ?? 0 const total = managersQuery.data?.total ?? 0
const jobs = jobsQuery.data ?? [] const jobs = jobsQuery.data ?? []
const openByManager = useMemo(() => {
const map = {}
for (const j of jobs) {
if (j.hiringManagerId && j.status === 'Open') {
const key = String(j.hiringManagerId)
map[key] = (map[key] || 0) + 1
}
}
return map
}, [jobs])
const totalReqs = jobs.filter((j) => j.status === 'Open').length const totalReqs = jobs.filter((j) => j.status === 'Open').length
const pages = Math.max(1, Math.ceil(total / pageSize)) const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pages) const currentPage = Math.min(page, pages)
@ -100,7 +110,7 @@ export default function Managers() {
</div> </div>
</div> </div>
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}> <div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div> <div className="stat-mini"><span className="stat-mini-val">{openByManager[String(m.id)] ?? m.openReqs ?? 0}</span><span className="stat-mini-lbl">Open Reqs</span></div>
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div> <div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
</div> </div>
<div className="divider" style={{ margin: '12px 0' }} /> <div className="divider" style={{ margin: '12px 0' }} />
@ -149,6 +159,16 @@ export default function Managers() {
function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast }) { function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast }) {
const [messaging, setMessaging] = useState(false) const [messaging, setMessaging] = useState(false)
const mineQuery = useQuery({
queryKey: qk.jobs.list({ hiringManagerId: m.id }),
queryFn: async () => {
const res = await jobsApi.list({ hiringManagerId: m.id, top: 100 })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(jobsApi.toJobView)
},
})
const mine = mineQuery.data ?? jobs.filter((j) => String(j.hiringManagerId) === String(m.id))
const openMine = mine.filter((j) => j.status === 'Open')
const send = useMutation({ const send = useMutation({
mutationFn: (body) => inboxApi.sendEmail({ to: m.email, subject: body.subject, body: body.body, contentType: 'text' }), mutationFn: (body) => inboxApi.sendEmail({ to: m.email, subject: body.subject, body: body.body, contentType: 'text' }),
onError: (err) => toast(friendlyAuthError(err, 'Could not send the message.'), 'error'), onError: (err) => toast(friendlyAuthError(err, 'Could not send the message.'), 'error'),
@ -234,8 +254,8 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })
) : ( ) : (
<> <>
<div className="grid g-3" style={{ marginBottom: 18 }}> <div className="grid g-3" style={{ marginBottom: 18 }}>
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div> <div className="stat-mini"><span className="stat-mini-val">{openMine.length}</span><span className="stat-mini-lbl">Open Reqs</span></div>
<div className="stat-mini"><span className="stat-mini-val">{jobs.length}</span><span className="stat-mini-lbl">Open Jobs</span></div> <div className="stat-mini"><span className="stat-mini-val">{mine.length}</span><span className="stat-mini-lbl">Jobs</span></div>
<div className="stat-mini"> <div className="stat-mini">
<span className="stat-mini-val">{m.email ? 'Yes' : '—'}</span> <span className="stat-mini-val">{m.email ? 'Yes' : '—'}</span>
<span className="stat-mini-lbl">Email on file</span> <span className="stat-mini-lbl">Email on file</span>
@ -259,14 +279,13 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })
</div> </div>
<h3 className="form-section-title">Open requisitions</h3> <h3 className="form-section-title">Open requisitions</h3>
<p className="text-muted text-sm" style={{ marginBottom: 10 }}>
Jobs are not linked to a hiring-manager id yet, so this list is the current open requisitions rather than this manager&apos;s own.
</p>
<div className="list-tight"> <div className="list-tight">
{jobs.filter((j) => j.status === 'Open').length === 0 ? ( {mineQuery.isPending ? (
<p className="text-muted">No open requisitions</p> <p className="text-muted">Loading requisitions</p>
) : openMine.length === 0 ? (
<p className="text-muted">No open requisitions for this manager</p>
) : ( ) : (
jobs.filter((j) => j.status === 'Open').slice(0, 8).map((j) => ( openMine.slice(0, 8).map((j) => (
<div <div
key={j.id} key={j.id}
className="list-row" className="list-row"

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; Needs assignment: job_post_id is null. Assign writes job_post_id (and a
skip = (page-1)*limit, same as Inbox. The AI's suggested_job_post_ids land candidate user) so Pipeline / Candidates fetch the row like any other
here as a radiogroup; Assign writes assigned_job_post_id. Nothing is manual upload. Assigned tab is the same origin with a job already linked.
marked read that stays Inbox's job so this page cannot silently move the
inbox nav badge.
============================================================ */ ============================================================ */
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import OpenResumeButton from '../ui/OpenResumeButton' import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
@ -23,34 +21,26 @@ import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox' import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts' import * as jobPostsApi from '../api/jobPosts'
import * as s3Api from '../api/s3' import * as s3Api from '../api/s3'
import { import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta,
} from '../data/seed'
const TABS = [ const TABS = [
{ key: 'needs', label: 'Needs assignment' }, { key: 'needs', label: 'Needs assignment' },
{ key: 'assigned', label: 'Assigned' }, { key: 'assigned', label: 'Assigned' },
{ key: 'none', label: 'No suggestions' },
{ key: 'all', label: 'All' }, { key: 'all', label: 'All' },
] ]
const TAB_FILTERS = { const TAB_FILTERS = {
needs: { assigned: false }, needs: { assigned: false },
assigned: { assigned: true }, assigned: { assigned: true },
none: { assigned: false, noSuggestions: true },
all: {}, all: {},
} }
/** Same cap as GET /inbox/all-applications `top`. */
const PAGE_SIZE_MAX = 500 const PAGE_SIZE_MAX = 500
const RESUME_STATUS = { const CV_BANK_META = { icon: 'file', color: 'var(--c2)', channel: 'Upload' }
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
}
function parseDate(value) { function parseDate(value) {
if (!value) return null if (!value) return null
@ -58,13 +48,39 @@ function parseDate(value) {
return Number.isNaN(d.getTime()) ? null : d return Number.isNaN(d.getTime()) ? null : d
} }
function sourceFrom(messageTo) { function mapRow(row) {
const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim() const name = row.name || row.email || row.file_name || 'Unknown'
if (!raw || raw === '[object Object]') return { source: 'Unknown', sourceMeta: null } return {
const flat = raw.toLowerCase().replace(/[^a-z]/g, '') id: String(row.id),
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, ''))) name,
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] } initials: initialsOf(name),
return { source: raw.split(',')[0].trim(), sourceMeta: null } color: avatarColor(name),
email: row.email || '',
position: row.file_name || 'CV bank',
source: 'CV bank',
sourceMeta: CV_BANK_META,
received: parseDate(row.created_at),
resumeText: row.resume_text || '',
filePath: row.file_path || '',
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
status: row.status || null,
userId: row.user_id || null,
linkedinUrl: row.linkedin_url || null,
}
}
async function fetchQueue(params) {
const res = await candidatesApi.listMatching(params)
const rows = Array.isArray(res?.data) ? res.data : []
return { rows: rows.map(mapRow), total: res?.total ?? rows.length }
}
async function fetchDetail(recordId) {
const res = await candidatesApi.getMatching(recordId)
const row = res?.data
if (!row) return null
return mapRow(row)
} }
function SourceChip({ item }) { function SourceChip({ item }) {
@ -76,106 +92,18 @@ function SourceChip({ item }) {
) )
} }
function htmlToText(value) {
const raw = (value || '').trim()
if (!raw) return ''
if (!/<[a-z!/]/i.test(raw)) return raw
const withBreaks = raw
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}
function mapApplication(row) {
const name = row.name || row.email || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.email || '',
position: row.position || '(no subject)',
...sourceFrom(row.source),
received: parseDate(row.received),
unread: Boolean(row.unread),
processing: row.processing || 'Unread',
resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending',
resumeText: row.resume_text || '',
filePath: row.file_path || '',
suggestedIds: Array.isArray(row.suggested_job_post_ids)
? row.suggested_job_post_ids.map(String)
: [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
matchStatus: row.match_status || null,
matchSummary: row.match_summary || '',
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
}
}
async function fetchApplications(params) {
const res = await inboxApi.listApplications(params)
const rows = Array.isArray(res?.data) ? res.data : []
return { rows: rows.map(mapApplication), total: res?.total ?? rows.length }
}
async function fetchDetail(recordId) {
const res = await inboxApi.getMessage(recordId)
const row = res?.data
if (!row) return null
const name = row.sender_name || row.fromEmail || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.fromEmail || '',
position: row.subject || '(no subject)',
// Same value as `position`, kept under its own name: the email panel renders
// it as a mail header, not as the candidate's role.
subject: row.subject || '',
...sourceFrom(row.message_to),
body: htmlToText(row.body),
// Kept raw for the HTML viewer; `body` stays as the plain-text fallback for
// mail that never had markup. EmailBody sanitises before rendering.
bodyHtml: row.body || '',
resumeText: row.resume_text || '',
files: Array.isArray(row.files) ? row.files : [],
filePath: row.file_path || '',
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
processing: row.unread ? 'Unread' : 'Read',
suggestedIds: (row.suggested_job_post_ids || []).map(String),
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
matchStatus: row.match_status || null,
matchSummary: row.match_summary || '',
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
}
}
function AssignmentBadge({ item, titleById }) { function AssignmentBadge({ item, titleById }) {
if (item.matchStatus === 'processing') {
return <Badge className="b-gray">Matching</Badge>
}
if (item.assignedId) { if (item.assignedId) {
const title = titleById.get(item.assignedId) || 'Assigned' const title = titleById.get(item.assignedId) || item.assignedPost?.title || 'Assigned'
return <Badge className="b-green">{title}</Badge> return <Badge className="b-green">{title}</Badge>
} }
const n = item.suggestedIds.length return <Badge className="b-amber">No job</Badge>
if (n > 0) return <Badge className="b-blue">{n} suggested</Badge>
return <Badge className="b-amber">No match</Badge>
} }
export default function Matching() { export default function Matching() {
const { toast } = useToast() const { toast } = useToast()
const { can } = useAuth() const { can } = useAuth()
const canEdit = can('inbox.edit') const canEdit = can('candidates.edit')
const qc = useQueryClient() const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const deepLink = searchParams.get('record') const deepLink = searchParams.get('record')
@ -188,7 +116,6 @@ export default function Matching() {
const [selection, setSelection] = useState(null) const [selection, setSelection] = useState(null)
const [manualPost, setManualPost] = useState(null) const [manualPost, setManualPost] = useState(null)
const [showPicker, setShowPicker] = useState(false) const [showPicker, setShowPicker] = useState(false)
const [whyOpen, setWhyOpen] = useState(false)
const tabFilter = TAB_FILTERS[tab] ?? {} const tabFilter = TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({ const listParams = useMemo(() => ({
@ -199,25 +126,21 @@ export default function Matching() {
}), [tabFilter, page, pageSize, q]) }), [tabFilter, page, pageSize, q])
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: qk.mailbox.assignments({ ...listParams, tab }), queryKey: qk.candidates.matching({ ...listParams, tab }),
queryFn: () => fetchApplications(listParams), queryFn: () => fetchQueue(listParams),
}) })
const needsCount = useQuery({ const needsCount = useQuery({
queryKey: qk.mailbox.assignments({ assigned: false, count: true }), queryKey: qk.candidates.matching({ assigned: false, count: true }),
queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0, queryFn: async () => (await candidatesApi.listMatching({ assigned: false, top: 1 }))?.total ?? 0,
}) })
const assignedCount = useQuery({ const assignedCount = useQuery({
queryKey: qk.mailbox.assignments({ assigned: true, count: true }), queryKey: qk.candidates.matching({ assigned: true, count: true }),
queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0, queryFn: async () => (await candidatesApi.listMatching({ assigned: true, top: 1 }))?.total ?? 0,
}) })
const allCount = useQuery({ const allCount = useQuery({
queryKey: qk.mailbox.assignments({ count: true }), queryKey: qk.candidates.matching({ count: true }),
queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0, queryFn: async () => (await candidatesApi.listMatching({ top: 1 }))?.total ?? 0,
})
const noneCountQuery = useQuery({
queryKey: qk.mailbox.assignments({ kind: 'none', count: true }),
queryFn: async () => (await inboxApi.listApplications({ assigned: false, noSuggestions: true, top: 1 }))?.total ?? 0,
}) })
const rows = listQuery.data?.rows ?? [] const rows = listQuery.data?.rows ?? []
@ -226,13 +149,10 @@ export default function Matching() {
const pages = Math.max(1, Math.ceil(total / pageSize)) const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pages) const currentPage = Math.min(page, pages)
const noneCount = noneCountQuery.data ?? 0
useEffect(() => { useEffect(() => {
if (listQuery.isSuccess && page > pages) setPage(pages) if (listQuery.isSuccess && page > pages) setPage(pages)
}, [listQuery.isSuccess, page, pages]) }, [listQuery.isSuccess, page, pages])
// Preselect deep link once, then clear the query so refresh doesn't re-pin.
useEffect(() => { useEffect(() => {
if (!deepLink) return undefined if (!deepLink) return undefined
setSelectedId(deepLink) setSelectedId(deepLink)
@ -241,7 +161,7 @@ export default function Matching() {
}, [deepLink, setSearchParams]) }, [deepLink, setSearchParams])
const detailQuery = useQuery({ const detailQuery = useQuery({
queryKey: qk.mailbox.message(selectedId), queryKey: qk.candidates.matchingDetail(selectedId),
queryFn: () => fetchDetail(selectedId), queryFn: () => fetchDetail(selectedId),
enabled: Boolean(selectedId), enabled: Boolean(selectedId),
}) })
@ -249,12 +169,10 @@ export default function Matching() {
const detail = detailQuery.data const detail = detailQuery.data
const listRow = filtered.find((r) => r.id === selectedId) || rows.find((r) => r.id === selectedId) const listRow = filtered.find((r) => r.id === selectedId) || rows.find((r) => r.id === selectedId)
// Hydrate titles for list badges (assigned + suggestions) in one call.
const hydrateIds = useMemo(() => { const hydrateIds = useMemo(() => {
const ids = new Set() const ids = new Set()
for (const r of rows) { for (const r of rows) {
if (r.assignedId) ids.add(r.assignedId) if (r.assignedId) ids.add(r.assignedId)
for (const id of r.suggestedIds) ids.add(id)
} }
return [...ids] return [...ids]
}, [rows]) }, [rows])
@ -275,24 +193,11 @@ export default function Matching() {
return map return map
}, [titlesQuery.data]) }, [titlesQuery.data])
// Reset local selection when the selected application changes.
useEffect(() => { useEffect(() => {
setManualPost(null) setManualPost(null)
setWhyOpen(false)
if (detail?.assignedId) setSelection(detail.assignedId) if (detail?.assignedId) setSelection(detail.assignedId)
else if (detail?.suggestedIds?.[0]) setSelection(detail.suggestedIds[0])
else setSelection(null) else setSelection(null)
}, [detail?.id, detail?.assignedId, detail?.suggestedIds]) }, [detail?.id, detail?.assignedId])
const suggestionCards = useMemo(() => {
const fromDetail = detail?.suggestedPosts || []
const byId = new Map(fromDetail.map((p) => [String(p.id), p]))
const ids = detail?.suggestedIds || listRow?.suggestedIds || []
return ids.map((id, i) => ({
rank: i + 1,
post: byId.get(id) || { id, unavailable: true },
}))
}, [detail, listRow])
const selectedPost = useMemo(() => { const selectedPost = useMemo(() => {
if (!selection) return null if (!selection) return null
@ -300,17 +205,11 @@ export default function Matching() {
if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) { if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) {
return detail.assignedPost return detail.assignedPost
} }
const hit = suggestionCards.find((c) => String(c.post.id) === String(selection)) return null
return hit?.post || null }, [selection, manualPost, detail])
}, [selection, manualPost, detail, suggestionCards])
const assignMutation = useMutation({ const assignMutation = useMutation({
mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId), mutationFn: ({ recordId, jobPostId }) => candidatesApi.assignMatchingJob(recordId, jobPostId),
onMutate: async ({ recordId, jobPostId }) => {
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
await qc.cancelQueries({ queryKey: ['mailbox', 'assignments'] })
return { recordId, jobPostId }
},
onError: (err) => { onError: (err) => {
toast(friendlyAuthError(err, 'Could not assign job post.'), 'error') toast(friendlyAuthError(err, 'Could not assign job post.'), 'error')
}, },
@ -321,12 +220,12 @@ export default function Matching() {
else toast(`${name} unassigned`, 'success') else toast(`${name} unassigned`, 'success')
}, },
onSettled: async (_res, _err, vars) => { onSettled: async (_res, _err, vars) => {
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
await qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] })
await qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
await qc.invalidateQueries({ queryKey: qk.candidates.all() }) await qc.invalidateQueries({ queryKey: qk.candidates.all() })
await qc.invalidateQueries({ queryKey: qk.cvBank.all() })
await qc.invalidateQueries({ queryKey: qk.pipeline.all() })
await qc.invalidateQueries({ queryKey: qk.analytics.all() })
await qc.invalidateQueries({ queryKey: qk.candidates.matchingDetail(vars.recordId) })
// Auto-advance only on the Needs assignment tab after a real assign.
if (tab === 'needs' && vars.jobPostId) { if (tab === 'needs' && vars.jobPostId) {
const idx = filtered.findIndex((r) => r.id === vars.recordId) const idx = filtered.findIndex((r) => r.id === vars.recordId)
const next = filtered[idx + 1] || filtered[idx - 1] || null const next = filtered[idx + 1] || filtered[idx - 1] || null
@ -335,17 +234,6 @@ export default function Matching() {
}, },
}) })
const rematchMutation = useMutation({
mutationFn: (recordId) => inboxApi.rematch(recordId),
onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'),
onSuccess: () => toast('Match re-queued', 'success'),
onSettled: (_r, _e, recordId) => {
qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) })
qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] })
},
})
// Keyboard: j/k move queue, 15 pick suggestion, Enter assigns, Esc clears.
useEffect(() => { useEffect(() => {
const onKey = (e) => { const onKey = (e) => {
const tag = e.target?.tagName const tag = e.target?.tagName
@ -360,9 +248,6 @@ export default function Matching() {
const idx = filtered.findIndex((r) => r.id === selectedId) const idx = filtered.findIndex((r) => r.id === selectedId)
const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))] const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))]
if (next) setSelectedId(next.id) if (next) setSelectedId(next.id)
} else if (e.key >= '1' && e.key <= '5') {
const card = suggestionCards[Number(e.key) - 1]
if (card && !card.post.unavailable) setSelection(String(card.post.id))
} else if (e.key === 'Enter' && canEdit && selection && selection !== detail?.assignedId) { } else if (e.key === 'Enter' && canEdit && selection && selection !== detail?.assignedId) {
e.preventDefault() e.preventDefault()
assignMutation.mutate({ recordId: selectedId, jobPostId: selection }) assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
@ -373,27 +258,25 @@ export default function Matching() {
} }
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey)
}, [filtered, selectedId, suggestionCards, canEdit, selection, detail, assignMutation]) }, [filtered, selectedId, canEdit, selection, detail, assignMutation])
const counts = { const counts = {
needs: needsCount.data ?? 0, needs: needsCount.data ?? 0,
assigned: assignedCount.data ?? 0, assigned: assignedCount.data ?? 0,
none: noneCount,
all: allCount.data ?? 0, all: allCount.data ?? 0,
} }
const resumeText = detail?.resumeText || listRow?.resumeText || ''
const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow) const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow)
const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus) const resumeText = detail?.resumeText || listRow?.resumeText || ''
return ( return (
<div className="page"> <div className="page">
<PageHeader title="Job Matching" sub="Route applications to the right open role" /> <PageHeader title="Job Matching" sub="Assign a job to CVs stored with no job" />
{!canEdit && ( {!canEdit && (
<div className="alert alert-danger mb-12"> <div className="alert alert-danger mb-12">
Your account does not hold <code>inbox.edit</code>, which the server requires to Your account does not hold <code>candidates.edit</code>, which the server requires to
assign, unassign, or retry a match. Controls below stay disabled. assign or unassign a role. Controls below stay disabled.
</div> </div>
)} )}
@ -425,7 +308,7 @@ export default function Matching() {
</div> </div>
<div> <div>
{listQuery.isPending && ( {listQuery.isPending && (
<EmptyState icon="target" title="Loading…">Fetching applications.</EmptyState> <EmptyState icon="target" title="Loading…">Fetching CVs from the bank.</EmptyState>
)} )}
{listQuery.isError && ( {listQuery.isError && (
<EmptyState icon="alert" title="Couldnt load queue"> <EmptyState icon="alert" title="Couldnt load queue">
@ -435,14 +318,14 @@ export default function Matching() {
{listQuery.isSuccess && filtered.length === 0 && ( {listQuery.isSuccess && filtered.length === 0 && (
<EmptyState icon="check-circle" title="Queue clear"> <EmptyState icon="check-circle" title="Queue clear">
{tab === 'needs' {tab === 'needs'
? 'Every application in this view has a role.' ? 'Every CV from the No job tab has a role, or the bank is empty.'
: 'Nothing matches this filter.'} : 'Nothing matches this filter.'}
</EmptyState> </EmptyState>
)} )}
{filtered.map((i) => ( {filtered.map((i) => (
<div <div
key={i.id} key={i.id}
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`} className={`inbox-item${selectedId === i.id ? ' active' : ''}`}
onClick={() => setSelectedId(i.id)} onClick={() => setSelectedId(i.id)}
> >
<Avatar name={i.name} initials={i.initials} color={i.color} /> <Avatar name={i.name} initials={i.initials} color={i.color} />
@ -480,13 +363,13 @@ export default function Matching() {
<div className="split-detail"> <div className="split-detail">
{!selectedId ? ( {!selectedId ? (
<div style={{ padding: '100px 20px' }}> <div style={{ padding: '100px 20px' }}>
<EmptyState icon="target" title="Select an application"> <EmptyState icon="target" title="Select a CV">
Choose an item from the list to review suggestions and assign a role. Choose an item from the list to assign a job post.
</EmptyState> </EmptyState>
</div> </div>
) : detailQuery.isError ? ( ) : detailQuery.isError ? (
<div style={{ padding: '100px 20px' }}> <div style={{ padding: '100px 20px' }}>
<EmptyState icon="alert" title="Couldnt load this application"> <EmptyState icon="alert" title="Couldnt load this CV">
{friendlyAuthError(detailQuery.error, 'Request failed')} {friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState> </EmptyState>
</div> </div>
@ -497,15 +380,9 @@ export default function Matching() {
loading={detailQuery.isPending} loading={detailQuery.isPending}
canEdit={canEdit} canEdit={canEdit}
selection={selection} selection={selection}
setSelection={setSelection}
manualPost={manualPost}
suggestionCards={suggestionCards}
selectedPost={selectedPost} selectedPost={selectedPost}
resumeText={resumeText} resumeText={resumeText}
resumeKey={resumeKey} resumeKey={resumeKey}
whyOpen={whyOpen}
setWhyOpen={setWhyOpen}
matchFailed={matchFailed}
onPickManual={() => setShowPicker(true)} onPickManual={() => setShowPicker(true)}
onSkip={() => { onSkip={() => {
const idx = filtered.findIndex((r) => r.id === selectedId) const idx = filtered.findIndex((r) => r.id === selectedId)
@ -521,9 +398,7 @@ export default function Matching() {
assignMutation.mutate({ recordId: selectedId, jobPostId: null }) assignMutation.mutate({ recordId: selectedId, jobPostId: null })
}} }}
onChange={() => setShowPicker(true)} onChange={() => setShowPicker(true)}
onRematch={() => rematchMutation.mutate(selectedId)}
assigning={assignMutation.isPending} assigning={assignMutation.isPending}
rematching={rematchMutation.isPending}
/> />
)} )}
</div> </div>
@ -549,23 +424,15 @@ function MatchingWorkspace({
loading, loading,
canEdit, canEdit,
selection, selection,
setSelection,
manualPost,
suggestionCards,
selectedPost, selectedPost,
resumeText, resumeText,
resumeKey, resumeKey,
whyOpen,
setWhyOpen,
matchFailed,
onPickManual, onPickManual,
onSkip, onSkip,
onAssign, onAssign,
onUnassign, onUnassign,
onChange, onChange,
onRematch,
assigning, assigning,
rematching,
}) { }) {
const i = { const i = {
name: detail?.name || listRow?.name || '…', name: detail?.name || listRow?.name || '…',
@ -574,8 +441,9 @@ function MatchingWorkspace({
position: detail?.position || listRow?.position, position: detail?.position || listRow?.position,
source: detail?.source || listRow?.source, source: detail?.source || listRow?.source,
sourceMeta: detail?.sourceMeta || listRow?.sourceMeta, sourceMeta: detail?.sourceMeta || listRow?.sourceMeta,
processing: detail?.processing || listRow?.processing, email: detail?.email || listRow?.email || '',
resumeStatus: detail?.resumeStatus || listRow?.resumeStatus, received: detail?.received || listRow?.received,
linkedinUrl: detail?.linkedinUrl || listRow?.linkedinUrl || null,
} }
const assigned = detail?.assignedPost const assigned = detail?.assignedPost
@ -588,20 +456,28 @@ function MatchingWorkspace({
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" /> <Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div className="flex-1"> <div className="flex-1">
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div> <div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div> <div className="ph-role">{i.email || i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}> <div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} />{' '} <SourceChip item={i} />{' '}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}> {i.received && <span className="cell-sub">Added {fmtDate(i.received)}</span>}
{i.resumeStatus}
</Badge>
{loading && <span className="cell-sub">Loading details</span>} {loading && <span className="cell-sub">Loading details</span>}
</div> </div>
</div> </div>
</div> </div>
{s3Api.canOpen(resumeKey) && ( {(s3Api.canOpen(resumeKey) || i.linkedinUrl) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}> <div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
<OpenResumeButton filePath={resumeKey} /> {s3Api.canOpen(resumeKey) && <OpenResumeButton filePath={resumeKey} />}
{i.linkedinUrl && (
<a
className="btn btn-secondary btn-sm"
href={i.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
>
<Icon name="linkedin" /> LinkedIn
</a>
)}
</div> </div>
)} )}
@ -626,10 +502,10 @@ function MatchingWorkspace({
</div> </div>
</div> </div>
<div className="flex gap-8"> <div className="flex gap-8">
<button className="btn btn-secondary btn-sm" disabled={!canEdit} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onChange}> <button className="btn btn-secondary btn-sm" disabled={!canEdit} title={!canEdit ? 'Requires candidates.edit' : undefined} onClick={onChange}>
Change Change
</button> </button>
<button className="btn btn-ghost btn-sm" disabled={!canEdit || assigning} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onUnassign}> <button className="btn btn-ghost btn-sm" disabled={!canEdit || assigning} title={!canEdit ? 'Requires candidates.edit' : undefined} onClick={onUnassign}>
Unassign Unassign
</button> </button>
</div> </div>
@ -646,86 +522,29 @@ function MatchingWorkspace({
}} }}
> >
<div style={{ flex: '1 1 320px', minWidth: 0 }}> <div style={{ flex: '1 1 320px', minWidth: 0 }}>
{matchFailed ? (
<div className="alert alert-danger mb-16">
<div className="mb-8">{detail?.matchError || 'Matching failed for this application.'}</div>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematching}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={onRematch}
>
<Icon name="sparkles" /> Retry match
</button>
</div>
) : (
<div className="mb-16">
<div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div>
<p style={{ marginBottom: 4 }}>
{detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'}
</p>
{detail?.matchedAt && (
<div className="cell-sub">Matched {fmtDate(detail.matchedAt)}</div>
)}
{(detail?.matchReasoning || listRow?.matchReasoning) && (
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: 8, paddingLeft: 0 }}
onClick={() => setWhyOpen((v) => !v)}
>
{whyOpen ? '▾' : '▸'} Why these roles?
</button>
)}
{whyOpen && (
<p className="text-muted text-sm mt-8">
{detail?.matchReasoning || listRow?.matchReasoning}
</p>
)}
</div>
)}
{/* Email first: it is the application itself, and the resume is its
attachment. Reading order follows that. */}
{(detail?.subject || detail?.body) && (
<div className="mb-16">
<div className="fw-600" style={{ marginBottom: 6 }}>Email</div>
<div className="email-head">Subject: {detail.subject || '(no subject)'}</div>
{looksLikeHtml(detail.bodyHtml) ? (
<EmailBody html={detail.bodyHtml} />
) : (
<pre className="resume-thumb is-full email-plain">
{detail.body || 'No email body.'}
</pre>
)}
</div>
)}
<div className="fw-600" style={{ marginBottom: 6 }}>CV</div> <div className="fw-600" style={{ marginBottom: 6 }}>CV</div>
{s3Api.canOpen(resumeKey) ? ( {s3Api.canOpen(resumeKey) ? (
<OpenResumeButton filePath={resumeKey} /> <OpenResumeButton filePath={resumeKey} />
) : ( ) : (
<p className="text-muted text-sm">No CV file stored in S3 for this application.</p> <p className="text-muted text-sm">No CV file stored in S3 for this record.</p>
)} )}
{resumeText ? (
<pre className="resume-thumb is-full" style={{ marginTop: 12, maxHeight: 280, overflow: 'auto' }}>
{resumeText}
</pre>
) : null}
</div> </div>
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}> <div role="radiogroup" aria-label="Job post" style={{ flex: '0 1 360px', minWidth: 260 }}>
<div className="fw-600 mb-8">Suggested roles</div> <div className="fw-600 mb-8">Job post</div>
{suggestionCards.length === 0 && !manualPost ? ( {!selectedPost ? (
<EmptyState icon="alert" title="No suggested roles"> <EmptyState icon="briefcase" title="No job selected">
<p>No job post was suggested. Choose a role manually.</p> <p>Pick a role for this CV. After assign it is a normal candidate on that job.</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematching}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={onRematch}
>
Retry match
</button>
<button <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
disabled={!canEdit} disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined} title={!canEdit ? 'Requires candidates.edit' : undefined}
onClick={onPickManual} onClick={onPickManual}
> >
Choose a role Choose a role
@ -733,24 +552,12 @@ function MatchingWorkspace({
</div> </div>
</EmptyState> </EmptyState>
) : ( ) : (
suggestionCards.map(({ rank, post }) => (
<JobCard <JobCard
key={post.id} post={selectedPost}
post={post}
rank={rank}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(id)}
resumeText={resumeText}
/>
))
)}
{manualPost && (
<JobCard
post={manualPost}
rank={0} rank={0}
manual manual
selected={String(selection) === String(manualPost.id)} selected={String(selection) === String(selectedPost.id)}
onSelect={(id) => setSelection(id)} onSelect={() => {}}
resumeText={resumeText} resumeText={resumeText}
/> />
)} )}
@ -758,10 +565,10 @@ function MatchingWorkspace({
className="btn btn-secondary" className="btn btn-secondary"
style={{ width: '100%', marginTop: 8 }} style={{ width: '100%', marginTop: 8 }}
disabled={!canEdit} disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined} title={!canEdit ? 'Requires candidates.edit' : undefined}
onClick={onPickManual} onClick={onPickManual}
> >
Choose a different role {selectedPost ? 'Choose a different role…' : 'Choose a role…'}
</button> </button>
</div> </div>
</div> </div>
@ -780,11 +587,10 @@ function MatchingWorkspace({
<button <button
className="btn btn-primary" className="btn btn-primary"
disabled={!canAssign} disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined} title={!canEdit ? 'Requires candidates.edit' : undefined}
onClick={onAssign} onClick={onAssign}
> >
{selectedPost?.title Assign
? 'Assign' :'Assign'}
</button> </button>
</div> </div>
</div> </div>

View File

@ -34,7 +34,9 @@ export const KANBAN_STAGES = [
{ name: 'Assessment', color: 'var(--stage-3)' }, { name: 'Assessment', color: 'var(--stage-3)' },
{ name: 'Interview', color: 'var(--stage-4)' }, { name: 'Interview', color: 'var(--stage-4)' },
{ name: 'Offer', color: 'var(--stage-5)' }, { name: 'Offer', color: 'var(--stage-5)' },
{ name: 'Approved', color: 'var(--stage-8)' },
{ name: 'Hired', color: 'var(--stage-6)' }, { name: 'Hired', color: 'var(--stage-6)' },
{ name: 'On Hold', color: 'var(--stage-9)' },
{ name: 'Rejected', color: 'var(--stage-7)' }, { name: 'Rejected', color: 'var(--stage-7)' },
] ]
@ -186,6 +188,9 @@ export default function Pipeline() {
// Stage lives on the inbox row every candidate screen reads, so their // Stage lives on the inbox row every candidate screen reads, so their
// caches are stale too the moment this lands. // caches are stale too the moment this lands.
qc.invalidateQueries({ queryKey: qk.candidates.all() }) qc.invalidateQueries({ queryKey: qk.candidates.all() })
// Dashboard / Analytics funnel is a separate cache; without this a drag
// to Interview leaves the doughnut on the previous snapshot for up to 60s.
qc.invalidateQueries({ queryKey: qk.analytics.all() })
}, },
}) })

View File

@ -19,20 +19,36 @@
/interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not /interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not
per recruiter the interviews table has no recruiter column and the card per recruiter the interviews table has no recruiter column and the card
says so rather than implying the selected person owns all of it. says so rather than implying the selected person owns all of it.
Tasks belong here too: GET /tasks/fetch?assignee_id= the selected recruiter
is the worklist the prototype filed under Recruiter Hub. Completing a row
writes the same /tasks/update the Tasks screen uses, so the two stay in
sync. Hidden without tasks.view; the rest of the hub still loads.
Interviews Today / upcoming / the heatmap join interviews job_posts via
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id) and
filter on current_recruiter_id. The leaderboard ranks by completed
requisitions (requisition_status=completed), not inbox hires.
============================================================ */ ============================================================ */
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Chart from '../ui/Chart' import Chart from '../ui/Chart'
import Charts from '../lib/charts' import Charts from '../lib/charts'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Avatar, EmptyState, Icon, KpiCard } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, KpiCard, PRIORITY_CLASS, ProgressBar } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as analyticsApi from '../api/analytics' import * as analyticsApi from '../api/analytics'
import * as interviewsApi from '../api/interviews' import * as interviewsApi from '../api/interviews'
import { avatarColor, initials as initialsOf } from '../data/seed' import * as tasksApi from '../api/tasks'
import { avatarColor, fmtShort, initials as initialsOf } from '../data/seed'
const TASK_PREVIEW = 8
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const WEEKS = 5 const WEEKS = 5
@ -50,7 +66,12 @@ function weekdayIndex(date) {
} }
export default function RecruiterHub() { export default function RecruiterHub() {
const { can } = useAuth()
const { toast } = useToast()
const qc = useQueryClient()
const [recruiterId, setRecruiterId] = useState('') const [recruiterId, setRecruiterId] = useState('')
const canViewTasks = can('tasks.view')
const canEditTasks = can('tasks.edit')
const boardQuery = useQuery({ const boardQuery = useQuery({
queryKey: qk.analytics.recruiters({ top: 50, scope: 'hub' }), queryKey: qk.analytics.recruiters({ top: 50, scope: 'hub' }),
@ -86,6 +107,37 @@ export default function RecruiterHub() {
enabled: Boolean(activeId), enabled: Boolean(activeId),
}) })
const tasksKey = qk.tasks.list({ assigneeId: activeId, scope: 'hub' })
const tasksQuery = useQuery({
queryKey: tasksKey,
queryFn: async () => {
const res = await tasksApi.list({ assigneeId: activeId })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(tasksApi.toTaskView)
},
enabled: Boolean(activeId) && canViewTasks,
})
const flip = useMutation({
mutationFn: ({ id, done }) => tasksApi.update(id, { status: done ? 'done' : 'open' }),
onMutate: async ({ id, done }) => {
await qc.cancelQueries({ queryKey: qk.tasks.all() })
const previous = qc.getQueryData(tasksKey)
qc.setQueryData(tasksKey, (old = []) =>
old.map((t) => (t.id === id ? { ...t, done } : t)),
)
return { previous }
},
onError: (err, _vars, ctx) => {
if (ctx?.previous) qc.setQueryData(tasksKey, ctx.previous)
toast(friendlyAuthError(err, 'Could not update the task.'), 'error')
},
onSuccess: (_res, { done }) => {
toast(done ? 'Task completed' : 'Task reopened', done ? 'success' : 'info')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
})
/* The heatmap window: the last five whole weeks ending today. Sent as a real /* The heatmap window: the last five whole weeks ending today. Sent as a real
range so the request stays small however long the table gets. */ range so the request stays small however long the table gets. */
const heatFrom = useMemo(() => { const heatFrom = useMemo(() => {
@ -96,16 +148,18 @@ export default function RecruiterHub() {
}, []) }, [])
const heatQuery = useQuery({ const heatQuery = useQuery({
queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS }), queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS, recruiterId: activeId }),
queryFn: async () => { queryFn: async () => {
const res = await interviewsApi.listRange({ const res = await interviewsApi.listRange({
fromDate: heatFrom.toISOString(), fromDate: heatFrom.toISOString(),
toDate: new Date().toISOString(), toDate: new Date().toISOString(),
recruiterId: activeId,
top: 500, top: 500,
}) })
const rows = Array.isArray(res?.data) ? res.data : [] const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(interviewsApi.toInterviewView) return rows.map(interviewsApi.toInterviewView)
}, },
enabled: Boolean(activeId),
}) })
const heatmap = useMemo(() => { const heatmap = useMemo(() => {
@ -135,7 +189,7 @@ export default function RecruiterHub() {
}, [trendQuery.data]) }, [trendQuery.data])
const pipelineData = useMemo(() => { const pipelineData = useMemo(() => {
const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED') const rows = analyticsApi.toBoardStageRows(funnelQuery.data ?? [])
return { return {
labels: rows.map((p) => p.stage), labels: rows.map((p) => p.stage),
data: rows.map((p) => p.count), data: rows.map((p) => p.count),
@ -144,7 +198,7 @@ export default function RecruiterHub() {
}, [funnelQuery.data]) }, [funnelQuery.data])
const board = useMemo( const board = useMemo(
() => [...recruiters].sort((a, b) => (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8), () => [...recruiters].sort((a, b) => (b.completed ?? 0) - (a.completed ?? 0) || (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8),
[recruiters], [recruiters],
) )
@ -186,12 +240,27 @@ export default function RecruiterHub() {
const k = kpisQuery.data const k = kpisQuery.data
const name = selected.name || 'Recruiter' const name = selected.name || 'Recruiter'
const loading = kpisQuery.isPending const loading = kpisQuery.isPending
const now = new Date()
const tasks = tasksQuery.data ?? []
const openTasks = tasks.filter((t) => !t.done)
const overdueCount = openTasks.filter((t) => t.due && t.due < now).length
const doneCount = tasks.filter((t) => t.done).length
const taskPct = tasks.length ? Math.round((doneCount / tasks.length) * 100) : 0
const tasksLoading = canViewTasks && tasksQuery.isPending
function toggleTask(task) {
if (!canEditTasks) {
toast('Requires tasks.edit', 'info')
return
}
flip.mutate({ id: task.id, done: !task.done })
}
return ( return (
<div className="page"> <div className="page">
<PageHeader <PageHeader
title="Recruiter Hub" title="Recruiter Hub"
sub="Per-recruiter performance, scoped server-side" sub="Per-recruiter hiring progress and assigned tasks"
actions={ actions={
<select className="select" value={selected.id} onChange={(e) => setRecruiterId(e.target.value)}> <select className="select" value={selected.id} onChange={(e) => setRecruiterId(e.target.value)}>
{recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)} {recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
@ -207,7 +276,15 @@ export default function RecruiterHub() {
<div style={{ opacity: 0.85 }}> <div style={{ opacity: 0.85 }}>
{selected.open_reqs ?? 0} open req{(selected.open_reqs ?? 0) === 1 ? '' : 's'} {selected.open_reqs ?? 0} open req{(selected.open_reqs ?? 0) === 1 ? '' : 's'}
{' · '} {' · '}
{selected.completed ?? 0} completed
{' · '}
{selected.hires ?? 0} hire{(selected.hires ?? 0) === 1 ? '' : 's'} {selected.hires ?? 0} hire{(selected.hires ?? 0) === 1 ? '' : 's'}
{canViewTasks && !tasksLoading ? (
<>
{' · '}
{openTasks.length} open task{openTasks.length === 1 ? '' : 's'}
</>
) : null}
</div> </div>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
@ -222,6 +299,14 @@ export default function RecruiterHub() {
</div> </div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Offer acceptance</div> <div style={{ opacity: 0.85, fontSize: 12 }}>Offer acceptance</div>
</div> </div>
{canViewTasks && (
<div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 600, letterSpacing: '-0.015em' }}>
{tasksLoading ? '—' : (tasks.length ? `${taskPct}%` : '—')}
</div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Tasks done</div>
</div>
)}
</div> </div>
</div> </div>
@ -245,6 +330,26 @@ export default function RecruiterHub() {
<KpiCard label="Offers Accepted" value={loading ? '—' : (k?.offers_accepted ?? 0)} icon="file" tone="i-green" /> <KpiCard label="Offers Accepted" value={loading ? '—' : (k?.offers_accepted ?? 0)} icon="file" tone="i-green" />
<KpiCard label="Candidates" value={loading ? '—' : (k?.total_candidates ?? 0)} icon="users" tone="i-indigo" foot="in their pipeline" /> <KpiCard label="Candidates" value={loading ? '—' : (k?.total_candidates ?? 0)} icon="users" tone="i-indigo" foot="in their pipeline" />
</div> </div>
{canViewTasks && (
<div className="grid g-kpi mb-18">
<KpiCard label="Open Tasks" value={tasksLoading ? '—' : openTasks.length} icon="check-square" tone="i-indigo" foot="assigned to them" />
<KpiCard label="Overdue" value={tasksLoading ? '—' : overdueCount} icon="alert" tone="i-red" foot="past due date" />
<KpiCard label="Completed" value={tasksLoading ? '—' : doneCount} icon="check-circle" tone="i-green" foot="of their worklist" />
<KpiCard label="Task Progress" value={tasksLoading ? '—' : (tasks.length ? `${taskPct}%` : '—')} icon="target" tone="i-teal" foot={tasks.length ? `${doneCount} of ${tasks.length}` : 'no tasks yet'} />
</div>
)}
{canViewTasks && (
<RecruiterTasks
name={name}
assigneeId={activeId}
query={tasksQuery}
now={now}
canEdit={canEditTasks}
toggling={flip.isPending}
onToggle={toggleTask}
/>
)}
<div className="grid g-2-1 mb-18"> <div className="grid g-2-1 mb-18">
<div className="card"> <div className="card">
@ -268,7 +373,7 @@ export default function RecruiterHub() {
<div className="card-head"> <div className="card-head">
<div> <div>
<h3>Interview Load</h3> <h3>Interview Load</h3>
<span className="ch-sub">Team-wide, last {WEEKS} weeks</span> <span className="ch-sub">This recruiter, last {WEEKS} weeks</span>
</div> </div>
</div> </div>
<div className="card-body"> <div className="card-body">
@ -307,7 +412,7 @@ export default function RecruiterHub() {
More More
</div> </div>
<p className="text-muted" style={{ marginTop: 10, fontSize: 12 }}> <p className="text-muted" style={{ marginTop: 10, fontSize: 12 }}>
<Icon name="info" /> Interviews carry no recruiter, so this counts the whole team. <Icon name="info" /> Counted from interviews on this recruiters jobs.
</p> </p>
</> </>
)} )}
@ -318,12 +423,12 @@ export default function RecruiterHub() {
<div className="grid g-2"> <div className="grid g-2">
<div className="card"> <div className="card">
<div className="card-head"> <div className="card-head">
<div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Top performers by hires</span></div> <div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Ranked by completed requisitions</span></div>
</div> </div>
<div className="card-body"> <div className="card-body">
{board.length === 0 ? ( {board.length === 0 ? (
<EmptyState icon="users" title="No hires recorded yet"> <EmptyState icon="users" title="No completed requisitions yet">
The board fills in as applications reach the hired stage. Mark a job Completed when hiring finishes to rank recruiters here.
</EmptyState> </EmptyState>
) : ( ) : (
board.map((rec, i) => { board.map((rec, i) => {
@ -349,8 +454,8 @@ export default function RecruiterHub() {
</div> </div>
</div> </div>
<div className="lr-right"> <div className="lr-right">
<div className="fw-600">{rec.hires ?? 0}</div> <div className="fw-600">{rec.completed ?? 0}</div>
<div className="lr-sub">hires</div> <div className="lr-sub">completed</div>
</div> </div>
</div> </div>
) )
@ -383,3 +488,95 @@ export default function RecruiterHub() {
</div> </div>
) )
} }
function RecruiterTasks({ name, assigneeId, query, now, canEdit, toggling, onToggle }) {
const tasks = query.data ?? []
const ranked = [...tasks].sort((a, b) => {
const aOver = !a.done && a.due && a.due < now
const bOver = !b.done && b.due && b.due < now
if (aOver !== bOver) return aOver ? -1 : 1
if (a.done !== b.done) return a.done ? 1 : -1
const aDue = a.due ? a.due.getTime() : Infinity
const bDue = b.due ? b.due.getTime() : Infinity
return aDue - bDue
})
const preview = ranked.slice(0, TASK_PREVIEW)
const done = tasks.filter((t) => t.done).length
const pctDone = tasks.length ? Math.round((done / tasks.length) * 100) : 0
return (
<div className="card mb-18">
<div className="card-head">
<div>
<h3>Tasks</h3>
<span className="ch-sub">Assigned to {name}</span>
</div>
<Link className="btn btn-ghost btn-sm" to={assigneeId ? `/tasks?assignee=${encodeURIComponent(assigneeId)}` : '/tasks'}>View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
{query.isPending ? (
<EmptyState icon="clock" title="Loading…">Fetching this 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 /* Order matters: "reached" is a running sum from the end of this list back to
the start. REJECTED is deliberately absent see the header note. */ the start. REJECTED, CLOSED (shown as Rejected on the board) and ONHOLD are
absent parking and outcomes are not a step in the happy-path suffix sum. */
const FUNNEL_ORDER = [ const FUNNEL_ORDER = [
{ key: 'PENDING', label: 'Shortlist' }, { key: 'PENDING', label: 'Shortlist' },
{ key: 'CLOSED', label: 'Shortlist' },
{ key: 'SCREENING', label: 'Screened' }, { key: 'SCREENING', label: 'Screened' },
{ key: 'PROCESS', label: 'Screened' }, { key: 'PROCESS', label: 'Screened' },
{ key: 'ONHOLD', label: 'Screened' },
{ key: 'ASSESSMENT', label: 'Assessed' }, { key: 'ASSESSMENT', label: 'Assessed' },
{ key: 'INTERVIEW', label: 'Interviewed' }, { key: 'INTERVIEW', label: 'Interviewed' },
{ key: 'OFFER', label: 'Offered' }, { key: 'OFFER', label: 'Offered' },
{ key: 'APPROVED', label: 'Hired' }, { key: 'APPROVED', label: 'Approved' },
{ key: 'HIRED', label: 'Hired' }, { key: 'HIRED', label: 'Hired' },
] ]

View File

@ -4,12 +4,11 @@
The layout, the toolbar, the card and the 8-tab profile modal are the The layout, the toolbar, the card and the 8-tab profile modal are the
originals, unchanged. Only the data source moved. originals, unchanged. Only the data source moved.
The endpoint returns name, email, experience, application_status and the The endpoint returns name, email, experience, application_status, suggested
suggested job title. It has no aiScore, skills, currentCompany, source or job titles and the attached job_posts (with department). It has no skills or
department the agent writes a verdict and prose, not a score, and no currentCompany on list rows those still come from the seed overlay. Each
résumé-derived skills are persisted. Each record is therefore OVERLAID on a record is therefore OVERLAID on a seed candidate: real values win, seed fills
seed candidate: real values win, seed fills the rest, so the card renders the rest, so the card renders exactly as it always did.
exactly as it always did.
Clicking a card opens CandidateProfile in place. It used to deep-link into Clicking a card opens CandidateProfile in place. It used to deep-link into
/candidates, which stopped resolving once the ids became real user_ids. /candidates, which stopped resolving once the ids became real user_ids.
@ -51,17 +50,7 @@ import { avatarColor, initials as initialsOf } from '../data/seed'
/** Backend GET /candidate/fetch caps `limit` at 100. */ /** Backend GET /candidate/fetch caps `limit` at 100. */
const PAGE_SIZE_MAX = 100 const PAGE_SIZE_MAX = 100
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
/**
* Candidate_application_Status (backend/inbox/enums.py) -> the seed stage
* vocabulary every screen renders. CLOSED is the column default, i.e. untriaged,
* so it reads as Shortlist rather than as an outcome.
*/
const STAGE_FROM_STATUS = {
PENDING: 'Shortlist', CLOSED: 'Shortlist', PROCESS: 'Screening',
ONHOLD: 'Screening', APPROVED: 'Hired', REJECTED: 'Rejected',
}
/** "6 years" -> 6. The column is free text, so anything unparseable defers to seed. */ /** "6 years" -> 6. The column is free text, so anything unparseable defers to seed. */
function years(value) { function years(value) {
@ -69,6 +58,25 @@ function years(value) {
return Number.isFinite(n) ? n : null return Number.isFinite(n) ? n : null
} }
/**
* Distinct departments from the candidate's assigned + suggested job posts.
* Seed templates also carry a department, but that is a prototype leftover and
* must not drive the toolbar filter it would never match /job/departments/fetch.
*/
function departmentsOf(row) {
const seen = new Set()
const out = []
const add = (value) => {
const d = typeof value === 'string' ? value : ''
if (!d || seen.has(d)) return
seen.add(d)
out.push(d)
}
add(row.assigned_job_post?.department)
for (const jp of row.job_posts || []) add(jp.department)
return out
}
/** /**
* One API record overlaid on one seed candidate. * One API record overlaid on one seed candidate.
* *
@ -82,8 +90,9 @@ function merge(row, template) {
const title = (row.job_posts || []).map((j) => j.title).find(Boolean) const title = (row.job_posts || []).map((j) => j.title).find(Boolean)
|| row.job_title || row.job_title
|| row.current_title || row.current_title
const stage = STAGE_FROM_STATUS[row.application_status] || template.stage const stage = pipelineApi.STAGE_FROM_STATUS[row.application_status] || template.stage
const experience = years(row.experience) const experience = years(row.experience)
const departments = departmentsOf(row)
return { return {
...template, ...template,
@ -97,6 +106,10 @@ function merge(row, template) {
status: stage, status: stage,
currentTitle: title || template.currentTitle, currentTitle: title || template.currentTitle,
jobTitle: title || template.jobTitle, jobTitle: title || template.jobTitle,
// Live job-post departments only. Seed department is left on `department`
// for the seed-only profile modal, but the filter reads `departments`.
departments,
department: departments[0] || template.department,
// Prefer real Form / platform tags from manual_upload; seed only as fallback. // Prefer real Form / platform tags from manual_upload; seed only as fallback.
source: row.source || template.source, source: row.source || template.source,
// NO seed fallback. `ai_score` is the candidate's current ats_results row, // NO seed fallback. `ai_score` is the candidate's current ats_results row,
@ -185,7 +198,7 @@ export default function TalentPool() {
const list = useMemo( const list = useMemo(
() => () =>
pool.filter((c) => { pool.filter((c) => {
if (dept && c.department !== dept) return false if (dept && !(c.departments || []).includes(dept)) return false
if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
return true return true
}), }),
@ -235,7 +248,7 @@ export default function TalentPool() {
</div> </div>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}> <select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option> <option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)} {departments.map((d) => <option key={d} value={d}>{d}</option>)}
</select> </select>
<PageSizeField <PageSizeField
value={pageSize} value={pageSize}

View File

@ -13,7 +13,7 @@
============================================================ */ ============================================================ */
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
@ -54,10 +54,12 @@ export default function Tasks() {
const { toast } = useToast() const { toast } = useToast()
const { can, user } = useAuth() const { can, user } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const qc = useQueryClient() const qc = useQueryClient()
const canCreate = can('tasks.create') && CREATOR_ROLES.includes(user?.role_name) const canCreate = can('tasks.create') && CREATOR_ROLES.includes(user?.role_name)
const canEdit = can('tasks.edit') const canEdit = can('tasks.edit')
const assigneeFilter = searchParams.get('assignee') || ''
const tasksQuery = useQuery({ queryKey: qk.tasks.list(), queryFn: fetchTasks }) const tasksQuery = useQuery({ queryKey: qk.tasks.list(), queryFn: fetchTasks })
const assigneesQuery = useQuery({ queryKey: qk.tasks.assignees(), queryFn: fetchAssignees }) const assigneesQuery = useQuery({ queryKey: qk.tasks.assignees(), queryFn: fetchAssignees })
@ -73,17 +75,30 @@ export default function Tasks() {
const now = new Date() const now = new Date()
const isOverdue = (t) => !t.done && t.due && t.due < now const isOverdue = (t) => !t.done && t.due && t.due < now
const list = useMemo(() => { const scoped = useMemo(() => {
if (filter === 'Open') return tasks.filter((t) => !t.done) if (!assigneeFilter) return tasks
if (filter === 'Completed') return tasks.filter((t) => t.done) return tasks.filter((t) => String(t.assigneeId) === String(assigneeFilter))
if (filter === 'Overdue') return tasks.filter(isOverdue) }, [tasks, assigneeFilter])
if (['High', 'Medium', 'Low'].includes(filter)) return tasks.filter((t) => t.priority === filter)
return tasks
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tasks, filter])
const openCount = tasks.filter((t) => !t.done).length const assigneeLabel = useMemo(() => {
const overdueCount = tasks.filter(isOverdue).length if (!assigneeFilter) return null
const fromTask = scoped.find((t) => t.assignee)?.assignee
if (fromTask) return fromTask
const fromPicker = (assigneesQuery.data ?? []).find((u) => String(u.id) === String(assigneeFilter))
return fromPicker?.name || null
}, [assigneeFilter, scoped, assigneesQuery.data])
const list = useMemo(() => {
if (filter === 'Open') return scoped.filter((t) => !t.done)
if (filter === 'Completed') return scoped.filter((t) => t.done)
if (filter === 'Overdue') return scoped.filter(isOverdue)
if (['High', 'Medium', 'Low'].includes(filter)) return scoped.filter((t) => t.priority === filter)
return scoped
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scoped, filter])
const openCount = scoped.filter((t) => !t.done).length
const overdueCount = scoped.filter(isOverdue).length
// Optimistic flip with rollback: the checkbox must not lag the click, but a // Optimistic flip with rollback: the checkbox must not lag the click, but a
// 403/422 must snap it back rather than lie. // 403/422 must snap it back rather than lie.
@ -158,7 +173,11 @@ export default function Tasks() {
<div className="page"> <div className="page">
<PageHeader <PageHeader
title="Tasks" title="Tasks"
sub={`${openCount} open · ${overdueCount} overdue`} sub={
assigneeFilter
? `${openCount} open · ${overdueCount} overdue · ${assigneeLabel || 'this recruiter'}`
: `${openCount} open · ${overdueCount} overdue`
}
actions={ actions={
<button <button
className="btn btn-primary" className="btn btn-primary"
@ -181,6 +200,17 @@ export default function Tasks() {
</button> </button>
))} ))}
</div> </div>
{assigneeFilter && (
<div className="flex items-center flex-wrap" style={{ gap: 8, marginTop: 12 }}>
<span className="text-muted" style={{ fontSize: 13 }}>
From Recruiter Hub
{assigneeLabel ? ` · ${assigneeLabel}` : ''}
</span>
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/tasks')}>
Show all recruiters
</button>
</div>
)}
</div> </div>
<div className="card-body"> <div className="card-body">
<div className="list-tight"> <div className="list-tight">

View File

@ -125,6 +125,7 @@
--stage-1: #0e7490; --stage-2: #5b60e8; --stage-3: #8a5a00; --stage-4: #004d43; --stage-1: #0e7490; --stage-2: #5b60e8; --stage-3: #8a5a00; --stage-4: #004d43;
--stage-5: #0f9d76; --stage-6: #6f8f14; --stage-7: #b3243a; --stage-5: #0f9d76; --stage-6: #6f8f14; --stage-7: #b3243a;
--stage-8: #0b6e4f; --stage-9: #b45309;
--ring: 0 0 0 3px rgba(0,77,67,.20); --ring: 0 0 0 3px rgba(0,77,67,.20);
/* select chevron: whole url() is tokenised so the stroke can follow the theme */ /* select chevron: whole url() is tokenised so the stroke can follow the theme */
@ -186,6 +187,7 @@
--stage-1: #5fd3e8; --stage-2: #8e92ff; --stage-3: #f5c451; --stage-4: #ceff71; --stage-1: #5fd3e8; --stage-2: #8e92ff; --stage-3: #f5c451; --stage-4: #ceff71;
--stage-5: #25e9a5; --stage-6: #a8e063; --stage-7: #ff7a8a; --stage-5: #25e9a5; --stage-6: #a8e063; --stage-7: #ff7a8a;
--stage-8: #5ee0b5; --stage-9: #ffb020;
--ring: 0 0 0 3px rgba(206,255,113,.28); --ring: 0 0 0 3px rgba(206,255,113,.28);
--chev-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238fada6' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); --chev-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238fada6' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
@ -993,13 +995,42 @@ canvas { width: 100%; max-width: 100%; display: block; }
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; } .ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
/* Inbox sidebar only: fit the list instead of scrolling sideways. /* Inbox sidebar only: fit the list instead of scrolling sideways.
Username (.ii-name) and subject (.ii-pos) are left alone. */ Username (.ii-name) and subject (.ii-pos) are left alone. */
.inbox-split { grid-template-columns: minmax(0, 380px) 1fr; } .inbox-split { grid-template-columns: minmax(0, 420px) 1fr; }
.inbox-queue { overflow-x: hidden; min-width: 0; } .inbox-queue { overflow-x: hidden; min-width: 0; }
.inbox-queue .inbox-item { min-width: 0; } .inbox-queue .inbox-item { min-width: 0; }
.inbox-queue .ii-meta { flex-wrap: wrap; min-width: 0; } /* Name + time on row 1, subject on row 2, chips span the full width under
the timestamp so a board address stays on one line. */
.inbox-queue .ii-main {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 12px;
align-items: start;
}
.inbox-queue .ii-name { grid-column: 1; grid-row: 1; }
.inbox-queue .ii-pos { grid-column: 1; grid-row: 2; }
.inbox-queue .ii-aside { grid-column: 2; grid-row: 1 / span 2; text-align: right; }
.inbox-queue .ii-meta {
grid-column: 1 / -1;
grid-row: 3;
flex-wrap: wrap;
min-width: 0;
overflow: visible;
row-gap: 6px;
}
/* One-line To-address pill: grow with the address, never wrap (wrapping
stretched the chip taller). Badges sit on the next row. */
.inbox-queue .source-chip { .inbox-queue .source-chip {
min-width: 0; max-width: 100%; flex: 0 0 auto;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: max-content;
max-width: 100%;
overflow: visible;
white-space: nowrap;
line-height: 1.25;
}
.inbox-queue .source-chip-label {
white-space: nowrap;
overflow-wrap: normal;
word-break: normal;
} }
.inbox-queue .toolbar-search { min-width: 0; } .inbox-queue .toolbar-search { min-width: 0; }
.inbox-queue .pagination { .inbox-queue .pagination {
@ -1050,7 +1081,8 @@ canvas { width: 100%; max-width: 100%; display: block; }
11px copy keeps its contrast in both modes. */ 11px copy keeps its contrast in both modes. */
.source-chip { .source-chip {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex; align-items: center; gap: 5px;
font-size: var(--fs-xs); font-weight: 600; padding: 2px 8px; border-radius: 20px; font-size: var(--fs-xs); font-weight: 600; line-height: 1.35;
padding: 3px 8px; border-radius: 20px;
--chip: var(--text-3); --chip: var(--text-3);
color: var(--text-2); color: var(--text-2);
background: var(--bg-sunken); /* fallback: color-mix needs Safari 16.2+ / Chrome 111+ */ background: var(--bg-sunken); /* fallback: color-mix needs Safari 16.2+ / Chrome 111+ */
@ -1058,6 +1090,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
} }
.source-chip svg { width: 12px; height: 12px; color: var(--chip); } .source-chip svg { width: 12px; height: 12px; color: var(--chip); }
.source-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--chip); } .source-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--chip); }
.source-chip-label { min-width: 0; }
.integration-status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 20px; font-size: var(--fs-sm); font-weight: 600; background: var(--success-soft); color: var(--success); } .integration-status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 20px; font-size: var(--fs-sm); font-weight: 600; background: var(--success-soft); color: var(--success); }
.integration-status.pending { background: var(--warning-soft); color: var(--warning); } .integration-status.pending { background: var(--warning-soft); color: var(--warning); }

View File

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