add hiring manager added

pull/34/head
ahmed.mujtaba 2026-08-30 21:49:08 +05:00
parent 7f3735362e
commit fd53730efe
20 changed files with 848 additions and 182 deletions

View File

@ -138,6 +138,8 @@ class JobUpdate(BaseModel):
experience_min: int | None = None
experience_max: int | None = None
description: str | None = None
current_recruiter_id: UUID | None = None
hiring_manager_id: UUID | None = None
class JobStatusUpdate(BaseModel):
@ -756,6 +758,7 @@ async def fetch_jobs(
department: str | None = Query(None),
requisition_status: str | None = Query(None),
employment_type: str | None = Query(None),
hiring_manager_id: str | None = Query(None),
# le=500 (not 100): the Jobs board loads a full client-side page for facets;
# a 200 ceiling used to 422 the SPA and render an empty requisition list.
top: int | None = Query(10, ge=1, le=500),
@ -771,7 +774,8 @@ async def fetch_jobs(
service=JobPost(session=session)
data,total=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,top=top,skip=skip,active_only=active_only,
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
top=top,skip=skip,active_only=active_only,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@ -786,6 +790,7 @@ async def export_jobs(
department: str | None = Query(None),
requisition_status: str | None = Query(None),
employment_type: str | None = Query(None),
hiring_manager_id: str | None = Query(None),
active_only: bool = Query(False),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)),
session: AsyncSession = Depends(get_session),
@ -795,7 +800,8 @@ async def export_jobs(
service=JobPost(session=session)
data,_=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,top=None,skip=0,active_only=active_only,
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
top=None,skip=0,active_only=active_only,
)
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
return Response(
@ -1168,12 +1174,16 @@ async def fetch_pipeline_transitions(
@router.get("/job/assignments/fetch")
async def fetch_job_assignments(
job_post_id:str=Query(...),
current_only:bool=Query(True),
assignment_role:str=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.list_job_assignments(job_post_id)
data=await service.list_job_assignments(
job_post_id,current_only=current_only,assignment_role=assignment_role,
)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise

View File

@ -40,17 +40,48 @@ class JobAssignments(SQLModel, table=True):
return result.scalars().first()
@classmethod
async def fetch_by_job(cls, session: AsyncSession, job_post_id, *, current_only: bool = True):
async def fetch_by_job(
cls,
session: AsyncSession,
job_post_id,
*,
current_only: bool = True,
assignment_role: str | None = None,
):
uid = cls._as_uuid(job_post_id)
if uid is None:
return []
statement = select(cls).where(cls.job_post_id == uid)
if current_only:
statement = statement.where(cls.valid_to.is_(None))
if assignment_role:
statement = statement.where(cls.assignment_role == assignment_role)
statement = statement.order_by(cls.valid_from.desc())
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def close_current(cls, session: AsyncSession, job_post_id, assignment_role):
"""End every open interval of this role on the job. Returns how many closed."""
uid = cls._as_uuid(job_post_id)
if uid is None or not assignment_role:
return 0
statement = select(cls).where(
cls.job_post_id == uid,
cls.assignment_role == assignment_role,
cls.valid_to.is_(None),
)
result = await session.execute(statement)
rows = list(result.scalars().all())
if not rows:
return 0
now = _now()
for row in rows:
row.valid_to = now
session.add(row)
await session.commit()
return len(rows)
@classmethod
async def insert_assignment(cls, session: AsyncSession, fields: dict):
row = cls(**fields)

View File

@ -1,24 +1,34 @@
def serialize_job_assignment(row) -> dict:
def serialize_job_assignment(row, names=None) -> dict:
names = names or {}
user_key = str(row.user_id) if row.user_id else None
by_key = str(row.assigned_by) if row.assigned_by else None
return {
"id": str(row.id),
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"user_id": str(row.user_id) if row.user_id else None,
"user_id": user_key,
"user_name": names.get(user_key) if user_key else None,
"assignment_role": row.assignment_role,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"assigned_by": str(row.assigned_by) if row.assigned_by else None,
"assigned_by": by_key,
"assigned_by_name": names.get(by_key) if by_key else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def serialize_application_assignment(row) -> dict:
def serialize_application_assignment(row, names=None) -> dict:
names = names or {}
user_key = str(row.user_id) if row.user_id else None
by_key = str(row.assigned_by) if row.assigned_by else None
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
"user_id": str(row.user_id) if row.user_id else None,
"user_id": user_key,
"user_name": names.get(user_key) if user_key else None,
"assignment_role": row.assignment_role,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"assigned_by": str(row.assigned_by) if row.assigned_by else None,
"assigned_by": by_key,
"assigned_by_name": names.get(by_key) if by_key else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}

View File

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

View File

@ -36,6 +36,7 @@ COLUMNS = [
("Status", 10),
("Publishing", 12),
("Recruiter", 18),
("Hiring Manager", 18),
("Created By", 18),
("Created", 13),
("Requirements", 46),
@ -126,6 +127,7 @@ def build_jobs_workbook(rows) -> bytes:
STATUS_LABELS.get(status_key, status_key),
row.get("status") or "",
row.get("recruiter_name") or "",
row.get("hiring_manager_name") or "",
row.get("created_by_name") or "",
_created(row),
_bullets(row.get("requirements")),
@ -145,7 +147,7 @@ def build_jobs_workbook(rows) -> bytes:
status_cell.alignment = center
if status_key in STATUS_COLORS:
status_cell.font = Font(bold=True, color=STATUS_COLORS[status_key])
created_cell = ws.cell(row=r, column=13)
created_cell = ws.cell(row=r, column=14)
if created_cell.value is not None:
created_cell.number_format = "dd mmm yyyy"
for c in (14, 15, 16):

View File

@ -20,12 +20,12 @@ class JobPosts(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str = Field(index=True)
# foreign_keys is required, not decoration: current_recruiter_id below is a
# SECOND foreign key into users.id, so the join condition is ambiguous without
# it and every mapper fails to initialize. `user` is the AUTHOR of the post —
# current_recruiter_id is deliberately a bare column with no relationship of
# its own, because Users already carries five selectin relations that load on
# every authenticated request. Same pairing as Notes.user / Notes.author.
# foreign_keys is required, not decoration: current_recruiter_id and
# hiring_manager_id below are extra FKs into users.id, so the join is
# ambiguous without it and every mapper fails to initialize. `user` is the
# AUTHOR of the post. The recruiter and hiring-manager columns stay bare —
# Users already carries five selectin relations that load on every
# authenticated request. Same pairing as Notes.user / Notes.author.
user: Optional["Users"] = Relationship(
back_populates="job_posts",
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
@ -56,7 +56,12 @@ class JobPosts(SQLModel, table=True):
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
# Who is working the req now (swappable). History lives in job_assignments
# with assignment_role=primary_recruiter; this column is the current pointer.
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
# Who owns the requisition (stable). Required at create. History lives in
# job_assignments with assignment_role=hiring_manager.
hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True)
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@ -136,6 +141,7 @@ class JobPosts(SQLModel, table=True):
department: str | None = None,
requisition_status: str | None = None,
employment_type: str | None = None,
hiring_manager_id: uuid.UUID | None = None,
):
if ids:
rows = await cls.get_by_ids(session, ids, active_only=active_only)
@ -157,6 +163,8 @@ class JobPosts(SQLModel, table=True):
statement = statement.where(cls.requisition_status == requisition_status)
if employment_type:
statement = statement.where(cls.employment_type == employment_type)
if hiring_manager_id is not None:
statement = statement.where(cls.hiring_manager_id == hiring_manager_id)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.created_at.desc())
@ -185,6 +193,24 @@ class JobPosts(SQLModel, table=True):
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
"""Open requisitions per hiring manager, keyed by users.id."""
uids = [u for u in (user_ids or []) if u]
if not uids:
return {}
statement = (
select(cls.hiring_manager_id, func.count())
.where(
cls.hiring_manager_id.in_(uids),
cls.requisition_status == "open",
cls.is_deleted == False, # noqa: E712
)
.group_by(cls.hiring_manager_id)
)
result = await session.execute(statement)
return {uid: int(n or 0) for uid, n in result.all()}
@classmethod
async def insert_job_post(cls, session: AsyncSession, fields: dict):
row = cls(**fields)

View File

@ -29,7 +29,7 @@ def serialize_job_post(row) -> dict:
}
def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict:
def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
"""Requisition view of a job post, for the Jobs screen.
Deliberately separate from serialize_job_post: that payload is shared by the
@ -58,6 +58,8 @@ def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict:
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
"current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None,
"recruiter_name": recruiter_name,
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
"hiring_manager_name": hiring_manager_name,
"applicant_count": applicant_count,
"created_by": str(row.created_by) if row.created_by else None,
"created_by_name": row.user.name if getattr(row, "user", None) else None,

View File

@ -3,6 +3,7 @@ import logging
import os
import uuid
from pathlib import Path
from uuid import UUID
import httpx
from dotenv import load_dotenv
@ -10,7 +11,9 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, model_validator
from inbox.models import Inbox_Messages
from job.assignment.views import Assignment
from job.job_post.models import JobPostImages,JobPosts,SocialPlatform
from role.models import EnumRoles
from users.models import Users
from job.job_post.plugins import (
BufferError,
@ -60,6 +63,8 @@ class JobPostCreate(BaseModel):
scheduler_time: time | None = time(0, 0, 0)
scheduler_date: date | None = None
due_at: str | None = None
hiring_manager_id: UUID
current_recruiter_id: UUID | None = None
@model_validator(mode="after")
def validate_mode_and_due_at(self):
@ -138,7 +143,24 @@ class JobPost:
# Column default is "linkedin"; an unpublished requisition must not
# masquerade as a LinkedIn post.
fields["platform"]="internal"
assignment=Assignment(self.session)
hm=await assignment.require_role(
payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id",
)
fields["hiring_manager_id"]=hm.id
rec=None
if payload.get("current_recruiter_id"):
rec=await assignment.require_role(
payload.get("current_recruiter_id"),EnumRoles.RECRUITER,"current_recruiter_id",
)
fields["current_recruiter_id"]=rec.id
row=await JobPosts.insert_job_post(self.session,fields)
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by)
if rec:
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
if not publish:
return serialize_job_post(row)
@ -187,20 +209,27 @@ class JobPost:
return await JobPosts.list_departments(self.session,active_only=active_only)
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
employment_type=None,top=None,skip=0,active_only=True):
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True):
hm_uid=None
if hiring_manager_id:
hm_uid=JobPosts._as_uuid(hiring_manager_id)
if hm_uid is None:
raise HTTPException(status_code=422,detail="hiring_manager_id must be a UUID")
rows,total=await JobPosts.fetch_job_posts(
self.session,search=search,top=top,skip=skip,active_only=active_only,
department=department,requisition_status=requisition_status,
employment_type=employment_type,
employment_type=employment_type,hiring_manager_id=hm_uid,
)
names=await Users.names_by_ids(
self.session,[r.current_recruiter_id for r in rows],
self.session,
[r.current_recruiter_id for r in rows]+[r.hiring_manager_id for r in rows],
)
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
return [
serialize_job_row(
r,
recruiter_name=names.get(str(r.current_recruiter_id)),
hiring_manager_name=names.get(str(r.hiring_manager_id)),
applicant_count=counts.get(str(r.id),0),
)
for r in rows
@ -208,13 +237,21 @@ class JobPost:
async def _job_row(self,row):
names=await Users.names_by_ids(
self.session,[row.current_recruiter_id] if row.current_recruiter_id else [],
self.session,
[row.current_recruiter_id,row.hiring_manager_id],
)
return serialize_job_row(
row,
recruiter_name=names.get(str(row.current_recruiter_id)),
hiring_manager_name=names.get(str(row.hiring_manager_id)),
)
return serialize_job_row(row,recruiter_name=names.get(str(row.current_recruiter_id)))
async def update_job(self,job_post_id,payload,current_user):
if not current_user:
raise HTTPException(status_code=401,detail="Not authenticated")
existing=await JobPosts.get_job_post_by_id(self.session,job_post_id)
if not existing or existing.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
allowed=("title","department","location","employment_type","vacancies",
"salary","experience_min","experience_max","description")
fields={k:payload[k] for k in allowed if k in payload}
@ -229,11 +266,41 @@ class JobPost:
fields["salary"]=str(high)
if "department" in fields and fields["department"] is None:
fields["department"]=""
assignment=Assignment(self.session)
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
hm_changed=False
rec_changed=False
if "hiring_manager_id" in payload:
raw=payload.get("hiring_manager_id")
if not raw:
raise HTTPException(status_code=422,detail="hiring_manager_id is required")
hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id")
fields["hiring_manager_id"]=hm.id
hm_changed=str(existing.hiring_manager_id)!=str(hm.id)
if "current_recruiter_id" in payload:
raw=payload.get("current_recruiter_id")
if raw is None or raw=="":
fields["current_recruiter_id"]=None
rec_changed=existing.current_recruiter_id is not None
else:
rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_id")
fields["current_recruiter_id"]=rec.id
rec_changed=str(existing.current_recruiter_id)!=str(rec.id)
if not fields:
raise HTTPException(status_code=400,detail="No fields to update")
row=await JobPosts.update_job_post(self.session,job_post_id,fields)
if not row:
raise HTTPException(status_code=404,detail="Job post not found")
if hm_changed:
await assignment.record_job_owner(
job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by,
)
if rec_changed:
await assignment.record_job_owner(
job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by,
)
return await self._job_row(row)
async def delete_job(self,job_post_id,current_user):

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

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

View File

@ -33,8 +33,8 @@ class Users(SQLModel, table=True):
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
# user row once per post. Without an explicit strategy the default is a lazy load,
# which raises MissingGreenlet the moment anything touches it under asyncio.
# foreign_keys must match the other side: job_posts.current_recruiter_id is a
# second FK into this table, so this relation has to say it means created_by.
# foreign_keys must match the other side: job_posts also has current_recruiter_id
# and hiring_manager_id into this table, so this relation has to say created_by.
job_posts: List[JobPosts] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},

View File

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

View File

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

View File

@ -9,13 +9,14 @@ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
* picker payload does not.
*/
export function list({ search, department, requisitionStatus, employmentType,
top, skip, activeOnly } = {}) {
hiringManagerId, top, skip, activeOnly } = {}) {
return request('/jobs/fetch', {
params: {
search,
department,
requisition_status: requisitionStatus,
employment_type: employmentType,
hiring_manager_id: hiringManagerId,
top,
skip,
active_only: activeOnly,
@ -48,6 +49,8 @@ export function toJobView(row) {
publishStatus: row.status,
recruiter: row.recruiter_name,
recruiterId: row.current_recruiter_id,
hiringManager: row.hiring_manager_name,
hiringManagerId: row.hiring_manager_id,
createdByName: row.created_by_name,
applicantCount: row.applicant_count ?? 0,
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.

View File

@ -9,6 +9,10 @@ export function list({ record_id, search, top, skip, roleId } = {}) {
return request('/users/fetch', { params: { record_id, search, top, skip, role_id: roleId } })
}
export function listManagers() {
return request('/managers/fetch')
}
export function listPendingApprovals() {
return request('/users/pending-approvals')
}

View File

@ -54,6 +54,7 @@ export const qk = {
managers: {
all: () => ['managers'],
list: (p = {}) => ['managers', 'list', p],
directory: () => ['managers', 'directory'],
},
orgSettings: {
all: () => ['orgSettings'],

View File

@ -26,6 +26,7 @@ import * as jobsApi from '../api/jobs'
import * as jobPostsApi from '../api/jobPosts'
import * as assignmentsApi from '../api/assignments'
import * as tasksApi from '../api/tasks'
import * as usersApi from '../api/users'
import { JOB_STATUSES } from '../api/jobs'
import { empTypes, fmtShort } from '../data/seed'
@ -183,7 +184,7 @@ export default function Jobs() {
if (type && j.type !== type) return false
if (q) {
const term = q.toLowerCase()
const hay = [j.title, j.department, j.recruiter, j.location]
const hay = [j.title, j.department, j.recruiter, j.hiringManager, j.location]
.filter(Boolean)
.join(' ')
.toLowerCase()
@ -226,6 +227,8 @@ export default function Jobs() {
{ key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ? <Badge className="b-gray">{platformLabel(j.platform)}</Badge> : '—' },
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> },
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
{ key: 'hiringManager', label: 'Hiring Manager', sortable: true, render: (j) => j.hiringManager || '—' },
{ key: 'recruiter', label: 'Recruiter', sortable: true, render: (j) => j.recruiter || '—' },
{
key: 'created', label: 'Created', sortable: true,
sortValue: (j) => (j.created ? j.created.getTime() : 0),
@ -374,9 +377,113 @@ const SECTION_LABEL = {
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/jpg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif'
const MAX_IMAGE_MB = 5
const ROLE_LABEL = { primary_recruiter: 'Recruiter', hiring_manager: 'Hiring manager' }
/**
* Searchable picker: type to filter, click a row to store the id.
* Not free-text the value is always an option id (or '' when allowEmpty).
*/
function SearchSelect({
options = [],
value,
onChange,
placeholder = 'Search…',
disabled = false,
loading = false,
allowEmpty = false,
emptyLabel = 'Unassigned',
error = false,
}) {
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const root = useRef(null)
const selected = options.find((o) => String(o.id) === String(value || ''))
useEffect(() => {
function onDoc(e) {
if (root.current && !root.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
const term = q.trim().toLowerCase()
const filtered = options.filter((o) => {
if (!term) return true
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
return hay.includes(term)
})
return (
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
<input
className={error ? 'err' : ''}
value={open ? q : (selected?.name || '')}
disabled={disabled || loading}
placeholder={loading ? 'Loading…' : placeholder}
autoComplete="off"
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
/>
{open && !disabled && !loading && (
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
{allowEmpty && (
<button
type="button"
className="dropdown-link"
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
>
{emptyLabel}
</button>
)}
{filtered.length === 0 && (
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
)}
{filtered.map((o) => (
<button
type="button"
key={o.id}
className="dropdown-link"
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
>
{o.name}
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
</button>
))}
</div>
)}
</div>
)
}
function useManagerDirectory() {
return useQuery({
queryKey: qk.managers.directory(),
queryFn: async () => {
const res = await usersApi.listManagers()
return Array.isArray(res?.data) ? res.data : []
},
retry: false,
})
}
function useRecruiterDirectory() {
return useQuery({
queryKey: qk.tasks.assignees(),
queryFn: async () => {
const res = await tasksApi.listAssignees()
return Array.isArray(res?.data) ? res.data : []
},
retry: false,
})
}
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const form = useFormState({
hiring_manager_id: '',
current_recruiter_id: '',
title: '',
department: '',
location: '',
@ -432,6 +539,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
const v = form.values
const errors = {}
if (!v.title.trim()) errors.title = 'Job title is required'
if (!v.hiring_manager_id) errors.hiring_manager_id = 'Hiring manager is required'
const vacancies = Number(v.vacancies)
if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1'
const expMin = v.experience_min === '' ? null : Number(v.experience_min)
@ -462,6 +570,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
requirements: splitLines(v.requirements),
optional_skills: splitLines(v.optional_skills),
description: v.description.trim() || null,
hiring_manager_id: v.hiring_manager_id,
current_recruiter_id: v.current_recruiter_id || undefined,
}, imageFile)
}
@ -522,6 +632,39 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
<FieldError>{form.errors.title}</FieldError>
</div>
<div className="form-field">
<label>Hiring manager <span className="req">*</span></label>
<SearchSelect
options={managersQuery.data ?? []}
value={form.values.hiring_manager_id}
onChange={(id) => form.setField('hiring_manager_id', id)}
placeholder="Search hiring managers…"
disabled={busy}
loading={managersQuery.isPending}
error={Boolean(form.errors.hiring_manager_id)}
/>
<FieldError>{form.errors.hiring_manager_id}</FieldError>
{managersQuery.isError && (
<p className="text-muted text-sm">Could not load hiring managers.</p>
)}
</div>
<div className="form-field">
<label>Recruiter</label>
<SearchSelect
options={recruitersQuery.data ?? []}
value={form.values.current_recruiter_id}
onChange={(id) => form.setField('current_recruiter_id', id)}
placeholder="Search recruiters…"
disabled={busy}
loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
{recruitersQuery.isError && (
<p className="text-muted text-sm">Recruiter list needs tasks.view you can assign later.</p>
)}
</div>
<div className="form-field">
<div className="field-label-row">
<label>Department</label>
@ -671,6 +814,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
}
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const form = useFormState({
title: j.title || '',
department: j.department || '',
@ -680,6 +825,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
experience_min: j.experienceMin != null ? String(j.experienceMin) : '',
experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
description: j.description || '',
hiring_manager_id: j.hiringManagerId || '',
current_recruiter_id: j.recruiterId || '',
})
const assistContext = () => ({
@ -707,10 +854,11 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
function submit() {
if (busy) return
const title = form.values.title.trim()
if (!title) {
form.setErrors({ title: 'Job title is required' })
return
}
const errors = {}
if (!title) errors.title = 'Job title is required'
if (!form.values.hiring_manager_id) errors.hiring_manager_id = 'Hiring manager is required'
form.setErrors(errors)
if (Object.keys(errors).length) return
onSubmit({
title,
department: form.values.department.trim() || null,
@ -720,6 +868,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min),
experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
description: form.values.description.trim() || null,
hiring_manager_id: form.values.hiring_manager_id,
current_recruiter_id: form.values.current_recruiter_id || null,
})
}
@ -748,6 +898,32 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
<input className={form.errors.title ? 'err' : ''} value={form.values.title} onChange={(e) => form.setField('title', e.target.value)} disabled={busy} />
<FieldError>{form.errors.title}</FieldError>
</div>
<div className="form-field">
<label>Hiring manager <span className="req">*</span></label>
<SearchSelect
options={managersQuery.data ?? []}
value={form.values.hiring_manager_id}
onChange={(id) => form.setField('hiring_manager_id', id)}
placeholder="Search hiring managers…"
disabled={busy}
loading={managersQuery.isPending}
error={Boolean(form.errors.hiring_manager_id)}
/>
<FieldError>{form.errors.hiring_manager_id}</FieldError>
</div>
<div className="form-field">
<label>Recruiter</label>
<SearchSelect
options={recruitersQuery.data ?? []}
value={form.values.current_recruiter_id}
onChange={(id) => form.setField('current_recruiter_id', id)}
placeholder="Search recruiters…"
disabled={busy}
loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
</div>
<div className="form-field">
<div className="field-label-row">
<label>Department</label>
@ -796,116 +972,117 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
}
/**
* Recruiter ownership of one requisition GET/POST /job/assignments/*.
*
* Rows are valid-time intervals and the fetch returns only the OPEN one, so
* "the assigned recruiter" is simply the first row back. There is no unassign
* route: posting a new assignment closes the previous interval, which is why
* the control is a picker with a Save rather than an assign/remove pair.
*
* The picker is /tasks/assignees/fetch because the server rejects any
* non-recruiter with a 422, and that endpoint returns exactly the active
* recruiter-role users without needing rbac_users.view.
* Hiring-manager + recruiter pointers on one requisition.
* Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
*/
function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) {
function JobOwnership({ job, canEdit }) {
const { toast } = useToast()
const qc = useQueryClient()
const [picked, setPicked] = useState('')
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const assigneesQuery = useQuery({
queryKey: qk.tasks.assignees(),
const historyQuery = useQuery({
queryKey: qk.assignments.job(job.id),
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 res = await assignmentsApi.listJob(job.id, { currentOnly: false })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((r) => assignmentsApi.toAssignmentView(r, namesById))
return rows.map((r) => assignmentsApi.toAssignmentView(r))
},
enabled: Boolean(jobPostId),
enabled: Boolean(job.id),
retry: false,
})
const current = currentQuery.data?.[0] ?? null
const assign = useMutation({
mutationFn: (userId) => assignmentsApi.assignJob({ jobPostId, userId }),
const patch = useMutation({
mutationFn: (body) => jobsApi.update(job.id, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.assignments.job(jobPostId) })
qc.invalidateQueries({ queryKey: qk.assignments.job(job.id) })
qc.invalidateQueries({ queryKey: qk.jobs.all() })
setPicked('')
toast('Recruiter assigned', 'success')
qc.invalidateQueries({ queryKey: qk.managers.all() })
toast('Assignment updated', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the recruiter.'), 'error'),
onError: (err) => toast(friendlyAuthError(err, 'Could not update the assignment.'), 'error'),
})
/* current.name resolves only once the assignee list has loaded; the
requisition's own recruiter_name is the fallback until then. */
const currentName = current?.name
|| (current ? namesById.get(String(current.userId)) : null)
|| fallbackName
|| null
const history = historyQuery.data ?? []
return (
<>
<div className="divider" />
<div className="mb-16">
<div style={SECTION_LABEL}>Recruiter ownership</div>
{currentQuery.isError ? (
<p className="text-muted text-sm">
{friendlyAuthError(currentQuery.error, 'Assignments did not load.')}
{' '}Needs the <code>jobs.view</code> permission.
</p>
) : (
<p className="text-muted text-sm" style={{ marginBottom: canEdit ? 10 : 0 }}>
{currentQuery.isPending
? 'Loading…'
: currentName
? <>Owned by <b>{currentName}</b>{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''}</>
: 'No recruiter assigned yet.'}
</p>
<div style={SECTION_LABEL}>Ownership</div>
<div className="form-grid" style={{ marginBottom: 12 }}>
<div className="form-field">
<label>Hiring manager</label>
{canEdit ? (
<SearchSelect
options={managersQuery.data ?? []}
value={job.hiringManagerId || ''}
onChange={(id) => {
if (!id || id === String(job.hiringManagerId || '')) return
patch.mutate({ hiring_manager_id: id })
}}
placeholder="Search hiring managers…"
disabled={patch.isPending}
loading={managersQuery.isPending}
/>
) : (
<p className="text-muted text-sm">{job.hiringManager || '—'}</p>
)}
</div>
<div className="form-field">
<label>Recruiter</label>
{canEdit ? (
<SearchSelect
options={recruitersQuery.data ?? []}
value={job.recruiterId || ''}
onChange={(id) => {
const next = id || null
if (String(next || '') === String(job.recruiterId || '')) return
patch.mutate({ current_recruiter_id: next })
}}
placeholder="Search recruiters…"
disabled={patch.isPending}
loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
) : (
<p className="text-muted text-sm">{job.recruiter || 'No recruiter assigned yet.'}</p>
)}
</div>
</div>
{canEdit && recruitersQuery.isError && (
<p className="text-muted text-sm">The recruiter list needs the <code>tasks.view</code> permission.</p>
)}
{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>
)}
{canEdit && assigneesQuery.isError && (
<div style={{ ...SECTION_LABEL, marginTop: 8 }}>Assignment history</div>
{historyQuery.isError ? (
<p className="text-muted text-sm">
The recruiter list needs the <code>tasks.view</code> permission.
{friendlyAuthError(historyQuery.error, 'History did not load.')}
</p>
) : historyQuery.isPending ? (
<p className="text-muted text-sm">Loading</p>
) : history.length === 0 ? (
<p className="text-muted text-sm">No assignment history yet.</p>
) : (
<div className="list-tight">
{history.map((row) => (
<div className="list-row" key={row.id}>
<div className="lr-main">
<div className="lr-title">{row.name || 'Unknown'}</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>
))}
</div>
)}
</div>
</>
@ -992,11 +1169,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">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div>
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
</div>
<RecruiterAssignment jobPostId={j.id} fallbackName={j.recruiter} canEdit={canEdit} />
<JobOwnership job={j} canEdit={canEdit} />
{j.description && (
<>

View File

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

View File

@ -19,20 +19,31 @@
/interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not
per recruiter the interviews table has no recruiter column and the card
says so rather than implying the selected person owns all of it.
Tasks belong here too: GET /tasks/fetch?assignee_id= the selected recruiter
is the worklist the prototype filed under Recruiter Hub. Completing a row
writes the same /tasks/update the Tasks screen uses, so the two stay in
sync. Hidden without tasks.view; the rest of the hub still loads.
============================================================ */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Chart from '../ui/Chart'
import Charts from '../lib/charts'
import PageHeader from '../ui/PageHeader'
import { Avatar, EmptyState, Icon, KpiCard } from '../ui/primitives'
import { Avatar, Badge, EmptyState, Icon, KpiCard, PRIORITY_CLASS, ProgressBar } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as analyticsApi from '../api/analytics'
import * as interviewsApi from '../api/interviews'
import { avatarColor, initials as initialsOf } from '../data/seed'
import * as tasksApi from '../api/tasks'
import { avatarColor, fmtShort, initials as initialsOf } from '../data/seed'
const TASK_PREVIEW = 8
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const WEEKS = 5
@ -50,7 +61,12 @@ function weekdayIndex(date) {
}
export default function RecruiterHub() {
const { can } = useAuth()
const { toast } = useToast()
const qc = useQueryClient()
const [recruiterId, setRecruiterId] = useState('')
const canViewTasks = can('tasks.view')
const canEditTasks = can('tasks.edit')
const boardQuery = useQuery({
queryKey: qk.analytics.recruiters({ top: 50, scope: 'hub' }),
@ -86,6 +102,37 @@ export default function RecruiterHub() {
enabled: Boolean(activeId),
})
const tasksKey = qk.tasks.list({ assigneeId: activeId, scope: 'hub' })
const tasksQuery = useQuery({
queryKey: tasksKey,
queryFn: async () => {
const res = await tasksApi.list({ assigneeId: activeId })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(tasksApi.toTaskView)
},
enabled: Boolean(activeId) && canViewTasks,
})
const flip = useMutation({
mutationFn: ({ id, done }) => tasksApi.update(id, { status: done ? 'done' : 'open' }),
onMutate: async ({ id, done }) => {
await qc.cancelQueries({ queryKey: qk.tasks.all() })
const previous = qc.getQueryData(tasksKey)
qc.setQueryData(tasksKey, (old = []) =>
old.map((t) => (t.id === id ? { ...t, done } : t)),
)
return { previous }
},
onError: (err, _vars, ctx) => {
if (ctx?.previous) qc.setQueryData(tasksKey, ctx.previous)
toast(friendlyAuthError(err, 'Could not update the task.'), 'error')
},
onSuccess: (_res, { done }) => {
toast(done ? 'Task completed' : 'Task reopened', done ? 'success' : 'info')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
})
/* The heatmap window: the last five whole weeks ending today. Sent as a real
range so the request stays small however long the table gets. */
const heatFrom = useMemo(() => {
@ -186,12 +233,27 @@ export default function RecruiterHub() {
const k = kpisQuery.data
const name = selected.name || 'Recruiter'
const loading = kpisQuery.isPending
const now = new Date()
const tasks = tasksQuery.data ?? []
const openTasks = tasks.filter((t) => !t.done)
const overdueCount = openTasks.filter((t) => t.due && t.due < now).length
const doneCount = tasks.filter((t) => t.done).length
const taskPct = tasks.length ? Math.round((doneCount / tasks.length) * 100) : 0
const tasksLoading = canViewTasks && tasksQuery.isPending
function toggleTask(task) {
if (!canEditTasks) {
toast('Requires tasks.edit', 'info')
return
}
flip.mutate({ id: task.id, done: !task.done })
}
return (
<div className="page">
<PageHeader
title="Recruiter Hub"
sub="Per-recruiter performance, scoped server-side"
sub="Per-recruiter hiring progress and assigned tasks"
actions={
<select className="select" value={selected.id} onChange={(e) => setRecruiterId(e.target.value)}>
{recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
@ -208,6 +270,12 @@ export default function RecruiterHub() {
{selected.open_reqs ?? 0} open req{(selected.open_reqs ?? 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 style={{ textAlign: 'center' }}>
@ -222,6 +290,14 @@ export default function RecruiterHub() {
</div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Offer acceptance</div>
</div>
{canViewTasks && (
<div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 600, letterSpacing: '-0.015em' }}>
{tasksLoading ? '—' : (tasks.length ? `${taskPct}%` : '—')}
</div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Tasks done</div>
</div>
)}
</div>
</div>
@ -245,6 +321,26 @@ export default function RecruiterHub() {
<KpiCard label="Offers Accepted" value={loading ? '—' : (k?.offers_accepted ?? 0)} icon="file" tone="i-green" />
<KpiCard label="Candidates" value={loading ? '—' : (k?.total_candidates ?? 0)} icon="users" tone="i-indigo" foot="in their pipeline" />
</div>
{canViewTasks && (
<div className="grid g-kpi mb-18">
<KpiCard label="Open Tasks" value={tasksLoading ? '—' : openTasks.length} icon="check-square" tone="i-indigo" foot="assigned to them" />
<KpiCard label="Overdue" value={tasksLoading ? '—' : overdueCount} icon="alert" tone="i-red" foot="past due date" />
<KpiCard label="Completed" value={tasksLoading ? '—' : doneCount} icon="check-circle" tone="i-green" foot="of their worklist" />
<KpiCard label="Task Progress" value={tasksLoading ? '—' : (tasks.length ? `${taskPct}%` : '—')} icon="target" tone="i-teal" foot={tasks.length ? `${doneCount} of ${tasks.length}` : 'no tasks yet'} />
</div>
)}
{canViewTasks && (
<RecruiterTasks
name={name}
assigneeId={activeId}
query={tasksQuery}
now={now}
canEdit={canEditTasks}
toggling={flip.isPending}
onToggle={toggleTask}
/>
)}
<div className="grid g-2-1 mb-18">
<div className="card">
@ -383,3 +479,95 @@ export default function RecruiterHub() {
</div>
)
}
function RecruiterTasks({ name, assigneeId, query, now, canEdit, toggling, onToggle }) {
const tasks = query.data ?? []
const ranked = [...tasks].sort((a, b) => {
const aOver = !a.done && a.due && a.due < now
const bOver = !b.done && b.due && b.due < now
if (aOver !== bOver) return aOver ? -1 : 1
if (a.done !== b.done) return a.done ? 1 : -1
const aDue = a.due ? a.due.getTime() : Infinity
const bDue = b.due ? b.due.getTime() : Infinity
return aDue - bDue
})
const preview = ranked.slice(0, TASK_PREVIEW)
const done = tasks.filter((t) => t.done).length
const pctDone = tasks.length ? Math.round((done / tasks.length) * 100) : 0
return (
<div className="card mb-18">
<div className="card-head">
<div>
<h3>Tasks</h3>
<span className="ch-sub">Assigned to {name}</span>
</div>
<Link className="btn btn-ghost btn-sm" to={assigneeId ? `/tasks?assignee=${encodeURIComponent(assigneeId)}` : '/tasks'}>View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
{query.isPending ? (
<EmptyState icon="clock" title="Loading…">Fetching this recruiters tasks.</EmptyState>
) : query.isError ? (
<EmptyState icon="alert" title="Couldnt load tasks">
{friendlyAuthError(query.error, 'The server did not answer.')}
{' '}This list needs the <code>tasks.view</code> permission.
</EmptyState>
) : preview.length === 0 ? (
<EmptyState icon="check-square" title="No tasks assigned">
Create a task on the Tasks screen and assign it to {name}.
</EmptyState>
) : (
<>
{preview.map((t) => {
const overdue = !t.done && t.due && t.due < now
return (
<div className="list-row" style={{ alignItems: 'center' }} key={t.id}>
<span
className={`checkbox ${t.done ? 'on' : ''}`}
onClick={() => onToggle(t)}
role="checkbox"
aria-checked={t.done}
tabIndex={canEdit ? 0 : -1}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle(t)
}
}}
>
<Icon name="check" />
</span>
<div className="lr-main">
<div
className="lr-title"
style={t.done ? { textDecoration: 'line-through', color: 'var(--text-3)' } : undefined}
>
{t.title}
</div>
<div
className="lr-sub"
style={overdue ? { color: 'var(--danger)', fontWeight: 600 } : undefined}
>
{t.due ? `${overdue ? 'Overdue · ' : 'Due '}${fmtShort(t.due)}` : 'No due date'}
</div>
</div>
<Badge className={PRIORITY_CLASS[t.priority]}>{t.priority}</Badge>
</div>
)
})}
<div className="task-foot">
<ProgressBar pct={pctDone} />
<span>
{done} of {tasks.length}
{ranked.length > TASK_PREVIEW ? ` · showing ${TASK_PREVIEW}` : ''}
{toggling ? ' · saving…' : ''}
</span>
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

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