diff --git a/backend/job/app.py b/backend/job/app.py index 1d46b48..73751ed 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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 diff --git a/backend/job/assignment/models.py b/backend/job/assignment/models.py index fe906b9..9d19580 100644 --- a/backend/job/assignment/models.py +++ b/backend/job/assignment/models.py @@ -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) diff --git a/backend/job/assignment/serializers.py b/backend/job/assignment/serializers.py index c1f22fc..f393a5d 100644 --- a/backend/job/assignment/serializers.py +++ b/backend/job/assignment/serializers.py @@ -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, } diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py index b5be521..e4a01f2 100644 --- a/backend/job/assignment/views.py +++ b/backend/job/assignment/views.py @@ -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) diff --git a/backend/job/job_post/export.py b/backend/job/job_post/export.py index 17d9199..0666f1d 100644 --- a/backend/job/job_post/export.py +++ b/backend/job/job_post/export.py @@ -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): diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 6765169..52f0fe2 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -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) diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 92c6e61..17b97ef 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -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, diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 21f6414..18c06d0 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -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): diff --git a/backend/migrations/manual/015_job_post_hiring_manager.sql b/backend/migrations/manual/015_job_post_hiring_manager.sql new file mode 100644 index 0000000..9b984c5 --- /dev/null +++ b/backend/migrations/manual/015_job_post_hiring_manager.sql @@ -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); diff --git a/backend/users/app.py b/backend/users/app.py index 47d5bbd..b540928 100644 --- a/backend/users/app.py +++ b/backend/users/app.py @@ -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), ): diff --git a/backend/users/models.py b/backend/users/models.py index 53b8500..3eff965 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -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]"}, diff --git a/backend/users/views.py b/backend/users/views.py index 4abd595..319db4e 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -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), diff --git a/frontend/src/api/assignments.js b/frontend/src/api/assignments.js index ab0a530..78ce874 100644 --- a/frontend/src/api/assignments.js +++ b/frontend/src/api/assignments.js @@ -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, diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index 4a268ea..bf6fa61 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -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. diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js index a0c112a..82626cb 100644 --- a/frontend/src/api/users.js +++ b/frontend/src/api/users.js @@ -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') } diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index a8907f1..9d845ec 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -54,6 +54,7 @@ export const qk = { managers: { all: () => ['managers'], list: (p = {}) => ['managers', 'list', p], + directory: () => ['managers', 'directory'], }, orgSettings: { all: () => ['orgSettings'], diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index 64a8e64..be5b9ae 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -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 ? {platformLabel(j.platform)} : '—' }, { key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => {j.vacancies ?? '—'} }, { key: 'status', label: 'Status', sortable: true, render: (j) => {j.status} }, + { 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 ( +
+ { setOpen(true); setQ('') }} + onChange={(e) => { setQ(e.target.value); setOpen(true) }} + /> + {open && !disabled && !loading && ( +
+ {allowEmpty && ( + + )} + {filtered.length === 0 && ( +
No matches
+ )} + {filtered.map((o) => ( + + ))} +
+ )} +
+ ) +} + +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 }) { {form.errors.title} +
+ + form.setField('hiring_manager_id', id)} + placeholder="Search hiring managers…" + disabled={busy} + loading={managersQuery.isPending} + error={Boolean(form.errors.hiring_manager_id)} + /> + {form.errors.hiring_manager_id} + {managersQuery.isError && ( +

Could not load hiring managers.

+ )} +
+
+ + form.setField('current_recruiter_id', id)} + placeholder="Search recruiters…" + disabled={busy} + loading={recruitersQuery.isPending} + allowEmpty + emptyLabel="Unassigned" + /> + {recruitersQuery.isError && ( +

Recruiter list needs tasks.view — you can assign later.

+ )} +
+
@@ -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 }) { form.setField('title', e.target.value)} disabled={busy} /> {form.errors.title}
+
+ + form.setField('hiring_manager_id', id)} + placeholder="Search hiring managers…" + disabled={busy} + loading={managersQuery.isPending} + error={Boolean(form.errors.hiring_manager_id)} + /> + {form.errors.hiring_manager_id} +
+
+ + form.setField('current_recruiter_id', id)} + placeholder="Search recruiters…" + disabled={busy} + loading={recruitersQuery.isPending} + allowEmpty + emptyLabel="Unassigned" + /> +
@@ -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 ( <>
-
Recruiter ownership
- {currentQuery.isError ? ( -

- {friendlyAuthError(currentQuery.error, 'Assignments did not load.')} - {' '}Needs the jobs.view permission. -

- ) : ( -

- {currentQuery.isPending - ? 'Loading…' - : currentName - ? <>Owned by {currentName}{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''} - : 'No recruiter assigned yet.'} -

+
Ownership
+
+
+ + {canEdit ? ( + { + if (!id || id === String(job.hiringManagerId || '')) return + patch.mutate({ hiring_manager_id: id }) + }} + placeholder="Search hiring managers…" + disabled={patch.isPending} + loading={managersQuery.isPending} + /> + ) : ( +

{job.hiringManager || '—'}

+ )} +
+
+ + {canEdit ? ( + { + 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" + /> + ) : ( +

{job.recruiter || 'No recruiter assigned yet.'}

+ )} +
+
+ {canEdit && recruitersQuery.isError && ( +

The recruiter list needs the tasks.view permission.

)} - {canEdit && !currentQuery.isError && ( -
- - -
- )} - {canEdit && assigneesQuery.isError && ( +
Assignment history
+ {historyQuery.isError ? (

- The recruiter list needs the tasks.view permission. + {friendlyAuthError(historyQuery.error, 'History did not load.')}

+ ) : historyQuery.isPending ? ( +

Loading…

+ ) : history.length === 0 ? ( +

No assignment history yet.

+ ) : ( +
+ {history.map((row) => ( +
+
+
{row.name || 'Unknown'}
+
+ {[ + 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(' · ')} +
+
+ {!row.validTo && Current} +
+ ))} +
)}
@@ -992,11 +1169,12 @@ function JobDetail({
Experience
{j.experience || '—'}
Created
{j.created ? fmtShort(j.created) : '—'}
Created by
{j.createdByName || '—'}
+
Hiring Manager
{j.hiringManager || '—'}
Assigned Recruiter
{j.recruiter || '—'}
Closed at
{j.closedAt ? fmtShort(j.closedAt) : '—'}
- + {j.description && ( <> diff --git a/frontend/src/screens/Managers.jsx b/frontend/src/screens/Managers.jsx index c46d068..83dbb17 100644 --- a/frontend/src/screens/Managers.jsx +++ b/frontend/src/screens/Managers.jsx @@ -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() {
-
{m.openReqs}Open Reqs
+
{openByManager[String(m.id)] ?? m.openReqs ?? 0}Open Reqs
{m.teamSize ?? '—'}Team Size
@@ -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 }) ) : ( <>
-
{m.openReqs}Open Reqs
-
{jobs.length}Open Jobs
+
{openMine.length}Open Reqs
+
{mine.length}Jobs
{m.email ? 'Yes' : '—'} Email on file @@ -259,14 +279,13 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })

Open requisitions

-

- Jobs are not linked to a hiring-manager id yet, so this list is the current open requisitions rather than this manager's own. -

- {jobs.filter((j) => j.status === 'Open').length === 0 ? ( -

No open requisitions

+ {mineQuery.isPending ? ( +

Loading requisitions…

+ ) : openMine.length === 0 ? ( +

No open requisitions for this manager

) : ( - jobs.filter((j) => j.status === 'Open').slice(0, 8).map((j) => ( + openMine.slice(0, 8).map((j) => (
@@ -222,6 +290,14 @@ export default function RecruiterHub() {
Offer acceptance
+ {canViewTasks && ( +
+
+ {tasksLoading ? '—' : (tasks.length ? `${taskPct}%` : '—')} +
+
Tasks done
+
+ )}
@@ -245,6 +321,26 @@ export default function RecruiterHub() {
+ {canViewTasks && ( +
+ + + + +
+ )} + + {canViewTasks && ( + + )}
@@ -383,3 +479,95 @@ export default function RecruiterHub() {
) } + +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 ( +
+
+
+

Tasks

+ Assigned to {name} +
+ View all +
+
+
+ {query.isPending ? ( + Fetching this recruiter’s tasks. + ) : query.isError ? ( + + {friendlyAuthError(query.error, 'The server did not answer.')} + {' '}This list needs the tasks.view permission. + + ) : preview.length === 0 ? ( + + Create a task on the Tasks screen and assign it to {name}. + + ) : ( + <> + {preview.map((t) => { + const overdue = !t.done && t.due && t.due < now + return ( +
+ onToggle(t)} + role="checkbox" + aria-checked={t.done} + tabIndex={canEdit ? 0 : -1} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle(t) + } + }} + > + + +
+
+ {t.title} +
+
+ {t.due ? `${overdue ? 'Overdue · ' : 'Due '}${fmtShort(t.due)}` : 'No due date'} +
+
+ {t.priority} +
+ ) + })} +
+ + + {done} of {tasks.length} + {ranked.length > TASK_PREVIEW ? ` · showing ${TASK_PREVIEW}` : ''} + {toggling ? ' · saving…' : ''} + +
+ + )} +
+
+
+ ) +} diff --git a/frontend/src/screens/Tasks.jsx b/frontend/src/screens/Tasks.jsx index 901b59c..5a5ee5b 100644 --- a/frontend/src/screens/Tasks.jsx +++ b/frontend/src/screens/Tasks.jsx @@ -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() {
))}
+ {assigneeFilter && ( +
+ + From Recruiter Hub + {assigneeLabel ? ` · ${assigneeLabel}` : ''} + + +
+ )}