add hiring manager added
parent
7f3735362e
commit
fd53730efe
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,34 @@
|
|||
def serialize_job_assignment(row) -> dict:
|
||||
def serialize_job_assignment(row, names=None) -> dict:
|
||||
names = names or {}
|
||||
user_key = str(row.user_id) if row.user_id else None
|
||||
by_key = str(row.assigned_by) if row.assigned_by else None
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||
"user_id": str(row.user_id) if row.user_id else None,
|
||||
"user_id": user_key,
|
||||
"user_name": names.get(user_key) if user_key else None,
|
||||
"assignment_role": row.assignment_role,
|
||||
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
||||
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
|
||||
"assigned_by": str(row.assigned_by) if row.assigned_by else None,
|
||||
"assigned_by": by_key,
|
||||
"assigned_by_name": names.get(by_key) if by_key else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_application_assignment(row) -> dict:
|
||||
def serialize_application_assignment(row, names=None) -> dict:
|
||||
names = names or {}
|
||||
user_key = str(row.user_id) if row.user_id else None
|
||||
by_key = str(row.assigned_by) if row.assigned_by else None
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"inbox_id": row.inbox_id,
|
||||
"user_id": str(row.user_id) if row.user_id else None,
|
||||
"user_id": user_key,
|
||||
"user_name": names.get(user_key) if user_key else None,
|
||||
"assignment_role": row.assignment_role,
|
||||
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
||||
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
|
||||
"assigned_by": str(row.assigned_by) if row.assigned_by else None,
|
||||
"assigned_by": by_key,
|
||||
"assigned_by_name": names.get(by_key) if by_key else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,58 +3,129 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from job.assignment.models import ApplicationAssignments, JobAssignments
|
||||
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
|
||||
from job.job_post.models import JobPosts
|
||||
from role.models import EnumRoles, Roles
|
||||
from users.models import Users
|
||||
|
||||
# job_assignments.assignment_role → the users.role that may hold it.
|
||||
# primary_recruiter is swappable; hiring_manager is the requisition owner.
|
||||
JOB_ASSIGNMENT_ROLES = {
|
||||
"primary_recruiter": EnumRoles.RECRUITER,
|
||||
"hiring_manager": EnumRoles.HIRING_MANAGER,
|
||||
}
|
||||
JOB_OWNER_COLUMN = {
|
||||
"primary_recruiter": "current_recruiter_id",
|
||||
"hiring_manager": "hiring_manager_id",
|
||||
}
|
||||
|
||||
|
||||
class Assignment:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def _require_recruiter(self,user_id):
|
||||
role=await Roles.get_role_by_name(self.session,EnumRoles.RECRUITER.value)
|
||||
async def require_role(self,user_id,role_enum,field_name):
|
||||
role=await Roles.get_role_by_name(self.session,role_enum.value)
|
||||
user=await Users.get_user_by_id(self.session,user_id)
|
||||
if not role or not user or user.role_id!=role.id:
|
||||
raise HTTPException(status_code=422,detail="user_id must be a recruiter")
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"{field_name} must be a {role_enum.value}",
|
||||
)
|
||||
if not user.is_active or user.is_deleted:
|
||||
raise HTTPException(status_code=422,detail=f"{field_name} is not an active user")
|
||||
return user
|
||||
|
||||
async def list_job_assignments(self,job_post_id):
|
||||
def _job_role(self,raw):
|
||||
key=(raw or "primary_recruiter").strip()
|
||||
if key=="recruiter":
|
||||
key="primary_recruiter"
|
||||
if key not in JOB_ASSIGNMENT_ROLES:
|
||||
allowed=", ".join(sorted(JOB_ASSIGNMENT_ROLES))
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"assignment_role must be one of {allowed}",
|
||||
)
|
||||
return key
|
||||
|
||||
async def record_job_owner(self,job_post_id,user_id,assignment_role,assigned_by):
|
||||
"""Close the open interval of this role, then open a new one.
|
||||
|
||||
user_id None = unassign (hiring_manager cannot be cleared; callers
|
||||
must not pass None for that role). No-ops when the same person already
|
||||
holds the open interval. Does not touch job_posts columns.
|
||||
"""
|
||||
role=self._job_role(assignment_role)
|
||||
job_uid=JobAssignments._as_uuid(job_post_id)
|
||||
by_uid=JobAssignments._as_uuid(assigned_by)
|
||||
if not job_uid or not by_uid:
|
||||
raise HTTPException(status_code=422,detail="Invalid job_post_id or assigned_by")
|
||||
current=await JobAssignments.fetch_by_job(
|
||||
self.session,job_uid,current_only=True,assignment_role=role,
|
||||
)
|
||||
if user_id is None:
|
||||
if role=="hiring_manager":
|
||||
raise HTTPException(status_code=422,detail="hiring_manager_id is required")
|
||||
await JobAssignments.close_current(self.session,job_uid,role)
|
||||
return None
|
||||
user_uid=JobAssignments._as_uuid(user_id)
|
||||
if not user_uid:
|
||||
raise HTTPException(status_code=422,detail="Invalid user_id")
|
||||
if current and str(current[0].user_id)==str(user_uid):
|
||||
return current[0]
|
||||
await JobAssignments.close_current(self.session,job_uid,role)
|
||||
return await JobAssignments.insert_assignment(self.session,{
|
||||
"job_post_id":job_uid,
|
||||
"user_id":user_uid,
|
||||
"assignment_role":role,
|
||||
"assigned_by":by_uid,
|
||||
})
|
||||
|
||||
async def list_job_assignments(self,job_post_id,current_only=True,assignment_role=None):
|
||||
if not job_post_id:
|
||||
raise HTTPException(status_code=400,detail="job_post_id is required")
|
||||
rows=await JobAssignments.fetch_by_job(self.session,job_post_id)
|
||||
return [serialize_job_assignment(r) for r in rows]
|
||||
role=self._job_role(assignment_role) if assignment_role else None
|
||||
rows=await JobAssignments.fetch_by_job(
|
||||
self.session,job_post_id,current_only=current_only,assignment_role=role,
|
||||
)
|
||||
names=await Users.names_by_ids(
|
||||
self.session,[r.user_id for r in rows]+[r.assigned_by for r in rows],
|
||||
)
|
||||
return [serialize_job_assignment(r,names=names) for r in rows]
|
||||
|
||||
async def create_job_assignment(self,payload,current_user):
|
||||
user_id=payload.get("user_id")
|
||||
job_post_id=payload.get("job_post_id")
|
||||
if not user_id or not job_post_id:
|
||||
raise HTTPException(status_code=422,detail="user_id and job_post_id are required")
|
||||
await self._require_recruiter(user_id)
|
||||
fields={
|
||||
"job_post_id":JobAssignments._as_uuid(job_post_id),
|
||||
"user_id":JobAssignments._as_uuid(user_id),
|
||||
"assignment_role":payload.get("assignment_role") or "primary_recruiter",
|
||||
"assigned_by":JobAssignments._as_uuid(
|
||||
current_user.get("id") if isinstance(current_user,dict) else None
|
||||
),
|
||||
}
|
||||
if not fields["job_post_id"] or not fields["user_id"] or not fields["assigned_by"]:
|
||||
raise HTTPException(status_code=422,detail="Invalid job_post_id, user_id, or assigned_by")
|
||||
row=await JobAssignments.insert_assignment(self.session,fields)
|
||||
return serialize_job_assignment(row)
|
||||
job=await JobPosts.get_job_post_by_id(self.session,job_post_id)
|
||||
if not job or job.is_deleted:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
role=self._job_role(payload.get("assignment_role"))
|
||||
await self.require_role(user_id,JOB_ASSIGNMENT_ROLES[role],"user_id")
|
||||
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||
row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
|
||||
column=JOB_OWNER_COLUMN[role]
|
||||
await JobPosts.update_job_post(self.session,job_post_id,{column:user_id})
|
||||
names=await Users.names_by_ids(
|
||||
self.session,[row.user_id,row.assigned_by] if row else [],
|
||||
)
|
||||
return serialize_job_assignment(row,names=names) if row else None
|
||||
|
||||
async def list_application_assignments(self,inbox_id):
|
||||
if inbox_id is None:
|
||||
raise HTTPException(status_code=400,detail="inbox_id is required")
|
||||
rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id))
|
||||
return [serialize_application_assignment(r) for r in rows]
|
||||
names=await Users.names_by_ids(
|
||||
self.session,[r.user_id for r in rows]+[r.assigned_by for r in rows],
|
||||
)
|
||||
return [serialize_application_assignment(r,names=names) for r in rows]
|
||||
|
||||
async def create_application_assignment(self,payload,current_user):
|
||||
user_id=payload.get("user_id")
|
||||
inbox_id=payload.get("inbox_id")
|
||||
if not user_id or inbox_id is None:
|
||||
raise HTTPException(status_code=422,detail="user_id and inbox_id are required")
|
||||
await self._require_recruiter(user_id)
|
||||
await self.require_role(user_id,EnumRoles.RECRUITER,"user_id")
|
||||
fields={
|
||||
"inbox_id":int(inbox_id),
|
||||
"user_id":ApplicationAssignments._as_uuid(user_id),
|
||||
|
|
@ -66,4 +137,5 @@ class Assignment:
|
|||
if not fields["user_id"] or not fields["assigned_by"]:
|
||||
raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by")
|
||||
row=await ApplicationAssignments.insert_assignment(self.session,fields)
|
||||
return serialize_application_assignment(row)
|
||||
names=await Users.names_by_ids(self.session,[row.user_id,row.assigned_by])
|
||||
return serialize_application_assignment(row,names=names)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -246,7 +246,12 @@ async def delete_user(
|
|||
@router.get("/managers/fetch")
|
||||
async def fetch_managers(
|
||||
current_user: dict = Depends(
|
||||
require_permission(PermissionTag.JOBS_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False)
|
||||
require_permission(
|
||||
PermissionTag.JOBS_VIEW,
|
||||
PermissionTag.CANDIDATES_VIEW,
|
||||
PermissionTag.JOB_BOARD_CREATE,
|
||||
require_all=False,
|
||||
)
|
||||
),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ class Users(SQLModel, table=True):
|
|||
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
|
||||
# user row once per post. Without an explicit strategy the default is a lazy load,
|
||||
# which raises MissingGreenlet the moment anything touches it under asyncio.
|
||||
# foreign_keys must match the other side: job_posts.current_recruiter_id is a
|
||||
# second FK into this table, so this relation has to say it means created_by.
|
||||
# foreign_keys must match the other side: job_posts also has current_recruiter_id
|
||||
# and hiring_manager_id into this table, so this relation has to say created_by.
|
||||
job_posts: List[JobPosts] = Relationship(
|
||||
back_populates="user",
|
||||
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export const qk = {
|
|||
managers: {
|
||||
all: () => ['managers'],
|
||||
list: (p = {}) => ['managers', 'list', p],
|
||||
directory: () => ['managers', 'directory'],
|
||||
},
|
||||
orgSettings: {
|
||||
all: () => ['orgSettings'],
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
|
||||
|
|
@ -53,6 +53,16 @@ export default function Managers() {
|
|||
const managers = managersQuery.data?.rows ?? []
|
||||
const total = managersQuery.data?.total ?? 0
|
||||
const jobs = jobsQuery.data ?? []
|
||||
const openByManager = useMemo(() => {
|
||||
const map = {}
|
||||
for (const j of jobs) {
|
||||
if (j.hiringManagerId && j.status === 'Open') {
|
||||
const key = String(j.hiringManagerId)
|
||||
map[key] = (map[key] || 0) + 1
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [jobs])
|
||||
const totalReqs = jobs.filter((j) => j.status === 'Open').length
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(page, pages)
|
||||
|
|
@ -100,7 +110,7 @@ export default function Managers() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{openByManager[String(m.id)] ?? m.openReqs ?? 0}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
|
||||
</div>
|
||||
<div className="divider" style={{ margin: '12px 0' }} />
|
||||
|
|
@ -149,6 +159,16 @@ export default function Managers() {
|
|||
|
||||
function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast }) {
|
||||
const [messaging, setMessaging] = useState(false)
|
||||
const mineQuery = useQuery({
|
||||
queryKey: qk.jobs.list({ hiringManagerId: m.id }),
|
||||
queryFn: async () => {
|
||||
const res = await jobsApi.list({ hiringManagerId: m.id, top: 100 })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(jobsApi.toJobView)
|
||||
},
|
||||
})
|
||||
const mine = mineQuery.data ?? jobs.filter((j) => String(j.hiringManagerId) === String(m.id))
|
||||
const openMine = mine.filter((j) => j.status === 'Open')
|
||||
const send = useMutation({
|
||||
mutationFn: (body) => inboxApi.sendEmail({ to: m.email, subject: body.subject, body: body.body, contentType: 'text' }),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not send the message.'), 'error'),
|
||||
|
|
@ -234,8 +254,8 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })
|
|||
) : (
|
||||
<>
|
||||
<div className="grid g-3" style={{ marginBottom: 18 }}>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{jobs.length}</span><span className="stat-mini-lbl">Open Jobs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{openMine.length}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{mine.length}</span><span className="stat-mini-lbl">Jobs</span></div>
|
||||
<div className="stat-mini">
|
||||
<span className="stat-mini-val">{m.email ? 'Yes' : '—'}</span>
|
||||
<span className="stat-mini-lbl">Email on file</span>
|
||||
|
|
@ -259,14 +279,13 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })
|
|||
</div>
|
||||
|
||||
<h3 className="form-section-title">Open requisitions</h3>
|
||||
<p className="text-muted text-sm" style={{ marginBottom: 10 }}>
|
||||
Jobs are not linked to a hiring-manager id yet, so this list is the current open requisitions rather than this manager's own.
|
||||
</p>
|
||||
<div className="list-tight">
|
||||
{jobs.filter((j) => j.status === 'Open').length === 0 ? (
|
||||
<p className="text-muted">No open requisitions</p>
|
||||
{mineQuery.isPending ? (
|
||||
<p className="text-muted">Loading requisitions…</p>
|
||||
) : openMine.length === 0 ? (
|
||||
<p className="text-muted">No open requisitions for this manager</p>
|
||||
) : (
|
||||
jobs.filter((j) => j.status === 'Open').slice(0, 8).map((j) => (
|
||||
openMine.slice(0, 8).map((j) => (
|
||||
<div
|
||||
key={j.id}
|
||||
className="list-row"
|
||||
|
|
|
|||
|
|
@ -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 recruiter’s tasks.</EmptyState>
|
||||
) : query.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load tasks">
|
||||
{friendlyAuthError(query.error, 'The server did not answer.')}
|
||||
{' '}This list needs the <code>tasks.view</code> permission.
|
||||
</EmptyState>
|
||||
) : preview.length === 0 ? (
|
||||
<EmptyState icon="check-square" title="No tasks assigned">
|
||||
Create a task on the Tasks screen and assign it to {name}.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{preview.map((t) => {
|
||||
const overdue = !t.done && t.due && t.due < now
|
||||
return (
|
||||
<div className="list-row" style={{ alignItems: 'center' }} key={t.id}>
|
||||
<span
|
||||
className={`checkbox ${t.done ? 'on' : ''}`}
|
||||
onClick={() => onToggle(t)}
|
||||
role="checkbox"
|
||||
aria-checked={t.done}
|
||||
tabIndex={canEdit ? 0 : -1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle(t)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="check" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div
|
||||
className="lr-title"
|
||||
style={t.done ? { textDecoration: 'line-through', color: 'var(--text-3)' } : undefined}
|
||||
>
|
||||
{t.title}
|
||||
</div>
|
||||
<div
|
||||
className="lr-sub"
|
||||
style={overdue ? { color: 'var(--danger)', fontWeight: 600 } : undefined}
|
||||
>
|
||||
{t.due ? `${overdue ? 'Overdue · ' : 'Due '}${fmtShort(t.due)}` : 'No due date'}
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={PRIORITY_CLASS[t.priority]}>{t.priority}</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="task-foot">
|
||||
<ProgressBar pct={pctDone} />
|
||||
<span>
|
||||
{done} of {tasks.length}
|
||||
{ranked.length > TASK_PREVIEW ? ` · showing ${TASK_PREVIEW}` : ''}
|
||||
{toggling ? ' · saving…' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue