Merge origin/main into Talha (requisitions, progress screens, ATS order-by)
commit
75972d29b2
|
|
@ -82,4 +82,7 @@ docker.local.frontend/dist/** */
|
|||
frontend/dist/index.html
|
||||
frontend/dist/index.html
|
||||
tests/**
|
||||
/backend/tests/**
|
||||
/backend/tests/**
|
||||
frontend/dist/**
|
||||
nginx.conf
|
||||
smoke.test.mjs
|
||||
|
|
@ -1,17 +1,53 @@
|
|||
from datetime import datetime
|
||||
|
||||
from datetime import datetime, date
|
||||
from dis import Positions
|
||||
from typing import Type
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
import uuid
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from typing import Optional
|
||||
from candidate_forms.plugins import definitions_payload
|
||||
from candidate_forms.views import CandidateForm
|
||||
from db_setup import get_session
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
from candidate_forms.enums import EmploymentType, Position, ReplacementFor, InternalRecommendate
|
||||
from candidate_forms.views import RequisitionForm
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class RequisitionFormCreate(BaseModel):
|
||||
form_type: str = "requisition"
|
||||
position:Position
|
||||
replacement_for:Optional[ReplacementFor]
|
||||
refferal_by:Optional[InternalRecommendate]
|
||||
initiated_by:Optional[str]
|
||||
initiated_date:Optional[date]
|
||||
recommended_by:Optional[str]
|
||||
recommended_date:Optional[date]
|
||||
approved_by_hr:Optional[bool]
|
||||
approved_by_date_hr:Optional[date]
|
||||
approved_by_vp:Optional[bool]
|
||||
approved_by_date_vp:Optional[date]
|
||||
approved_by_svp:Optional[bool]
|
||||
approved_by_date_svp:Optional[date]
|
||||
|
||||
|
||||
class RequisitionFormUpdate(BaseModel):
|
||||
position:Optional[Position]=None
|
||||
replacement_for:Optional[ReplacementFor]=None
|
||||
refferal_by:Optional[InternalRecommendate]=None
|
||||
initiated_by:Optional[str]=None
|
||||
initiated_date:Optional[date]=None
|
||||
recommended_by:Optional[str]=None
|
||||
recommended_date:Optional[date]=None
|
||||
approved_by_hr:Optional[bool]=None
|
||||
approved_by_date_hr:Optional[date]=None
|
||||
approved_by_vp:Optional[bool]=None
|
||||
approved_by_date_vp:Optional[date]=None
|
||||
approved_by_svp:Optional[bool]=None
|
||||
approved_by_date_svp:Optional[date]=None
|
||||
|
||||
|
||||
class FormCreate(BaseModel):
|
||||
form_type: str
|
||||
|
|
@ -32,6 +68,85 @@ class FormUpdate(BaseModel):
|
|||
fields: dict | None = None
|
||||
recommendation: str | None = None
|
||||
|
||||
@router.get("/forms/requisition/search")
|
||||
async def search_requisitions(
|
||||
current_user: dict = Depends(
|
||||
require_permission(
|
||||
PermissionTag.REQUISITIONS_VIEW,
|
||||
PermissionTag.JOB_BOARD_CREATE,
|
||||
PermissionTag.JOBS_CREATE,
|
||||
require_all=False,
|
||||
)
|
||||
),
|
||||
q: str | None = Query(None),
|
||||
top: int = Query(50, ge=1, le=100),
|
||||
job_post_id: uuid.UUID | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Searchable picker for job create: `{position_title} - {department}`.
|
||||
|
||||
`q` matches either field (ilike). Empty `q` returns recent rows.
|
||||
Linked requisitions are omitted (1:1 with job posts). Pass `job_post_id`
|
||||
on edit so the job's current requisition remains selectable until unlinked.
|
||||
"""
|
||||
try:
|
||||
service = RequisitionForm(session=session)
|
||||
data = await service.search(q, top=top, job_post_id=job_post_id)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/forms/requisition/fetch")
|
||||
async def fetch_requisition_form(
|
||||
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
|
||||
form_id:str=Query(None),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
"""Requisition table. Admins get every non-deleted row (job link does not
|
||||
hide anything). Other roles stay scoped to created_by."""
|
||||
try:
|
||||
service=RequisitionForm(session=session)
|
||||
data=await service.get_form_by_id(form_id,current_user)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/forms/requisition/create")
|
||||
async def create_requisition_form(
|
||||
payload: RequisitionFormCreate,
|
||||
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_CREATE)),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = RequisitionForm(session=session)
|
||||
data = await service.create_form(payload.model_dump(exclude_unset=True), current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/forms/requisition/update")
|
||||
async def update_requisition_form(
|
||||
payload: RequisitionFormUpdate,
|
||||
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_EDIT)),
|
||||
form_id:str=Query(...),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=RequisitionForm(session=session)
|
||||
data=await service.update_form(form_id,payload.model_dump(exclude_unset=True),current_user)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@router.get("/forms/definitions")
|
||||
async def fetch_form_definitions(
|
||||
|
|
@ -61,6 +176,7 @@ async def fetch_forms(
|
|||
service = CandidateForm(session=session)
|
||||
data, summary, total = await service.get_forms(
|
||||
form_id, inbox_id, manual_upload_candidate_id, job_post_id, form_type, top, skip,
|
||||
current_user=current_user,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"data": data, "summary": summary, "total": total, "status_code": 200}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
from enum import Enum
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
class EmploymentType(str,Enum):
|
||||
PERMANENT = "permanent"
|
||||
CONTRACT = "contract"
|
||||
TEMPORARY = "temporary"
|
||||
INTERNEE="internee"
|
||||
|
||||
class Position(BaseModel):
|
||||
department:Optional[str]
|
||||
title:Optional[str]
|
||||
date:Optional[date]
|
||||
date_needed:Optional[date]
|
||||
type:Optional[EmploymentType]
|
||||
job_description:Optional[str]
|
||||
|
||||
class InternalRecommendate(BaseModel):
|
||||
employee_name:Optional[str]=None
|
||||
employee_department:Optional[str]=None
|
||||
|
||||
class ReplacementFor(BaseModel):
|
||||
to_replace:Optional[str]
|
||||
grade:Optional[str]
|
||||
title:Optional[str]
|
||||
date_separated:Optional[date]
|
||||
justification:Optional[str]
|
||||
budget:Optional[str]
|
||||
recommended_grade:Optional[str]
|
||||
|
|
@ -1,14 +1,235 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, date as Date, timezone
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, JSON, func
|
||||
from sqlalchemy import DateTime, Enum as SAEnum, JSON, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
from candidate_forms.enums import EmploymentType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
class Requisition(SQLModel, table=True):
|
||||
__tablename__ = "requisitions"
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
|
||||
department: Optional[str] = None
|
||||
position_title: Optional[str] = None
|
||||
date: Optional[Date] = None
|
||||
date_needed: Optional[Date] = None
|
||||
employment_type: Optional[EmploymentType] = Field(
|
||||
default=None,
|
||||
sa_type=SAEnum(
|
||||
EmploymentType,
|
||||
name="employmenttype",
|
||||
schema="app",
|
||||
native_enum=True,
|
||||
values_callable=lambda enum: [member.value for member in enum],
|
||||
),
|
||||
)
|
||||
job_description: Optional[str] = None
|
||||
|
||||
employee_name: Optional[str] = None
|
||||
employee_department: Optional[str] = None
|
||||
|
||||
to_replace: Optional[str] = None
|
||||
grade: Optional[str] = None
|
||||
recruitment_title: Optional[str] = None
|
||||
date_separated: Optional[Date] = None
|
||||
justification: Optional[str] = None
|
||||
budget: Optional[str] = None
|
||||
recommended_grade: Optional[str] = None
|
||||
|
||||
initiated_by: Optional[str] = None
|
||||
initiated_date: Optional[Date] = None
|
||||
recommended_by: Optional[str] = None
|
||||
recommended_date: Optional[Date] = None
|
||||
approved_by_hr: Optional[bool] = None
|
||||
approved_by_date_hr: Optional[Date] = None
|
||||
approved_by_vp: Optional[bool] = None
|
||||
approved_by_date_vp: Optional[Date] = None
|
||||
approved_by_svp: Optional[bool] = None
|
||||
approved_by_date_svp: Optional[Date] = None
|
||||
created_by: Optional[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))
|
||||
is_deleted: bool = Field(default=False)
|
||||
# Optional 1:1: job_posts.requisition_id points here. uselist=False so a
|
||||
# requisition has at most one job post (enforced in DB by the unique FK).
|
||||
job_post: Optional["JobPosts"] = Relationship(
|
||||
back_populates="requisition",
|
||||
sa_relationship_kwargs={"uselist": False, "lazy": "selectin"},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=None):
|
||||
qry = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||
if created_by is not None:
|
||||
qry = qry.where(cls.created_by == created_by)
|
||||
if record_id not in (None, ""):
|
||||
|
||||
try:
|
||||
uid = uuid.UUID(str(record_id))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
qry = qry.where(cls.id == uid)
|
||||
qry = qry.order_by(cls.created_at.desc(),cls.id.desc())
|
||||
result = await session.execute(qry)
|
||||
return result.scalars().first()
|
||||
result = await session.execute(qry.order_by(cls.created_at.desc(),cls.id.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def search(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
q: str | None = None,
|
||||
*,
|
||||
top: int = 50,
|
||||
job_post_id=None,
|
||||
):
|
||||
"""Dropdown rows: match position_title or department (either side).
|
||||
|
||||
Empty `q` returns the most recent non-deleted rows so the picker has a
|
||||
list before the user types. Not scoped to created_by — job creators
|
||||
need the org-wide list, not only requisitions they opened themselves.
|
||||
|
||||
job_posts.requisition_id is 1:1. Hide requisitions already linked to a
|
||||
live job post. Pass `job_post_id` when editing so that job's current
|
||||
requisition stays in the list until the link is cleared.
|
||||
"""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
statement = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||
held = select(JobPosts.requisition_id).where(
|
||||
JobPosts.requisition_id.is_not(None),
|
||||
JobPosts.is_deleted == False, # noqa: E712
|
||||
)
|
||||
except_uid = JobPosts._as_uuid(job_post_id) if job_post_id else None
|
||||
if except_uid is not None:
|
||||
held = held.where(JobPosts.id != except_uid)
|
||||
statement = statement.where(cls.id.notin_(held))
|
||||
term = (q or "").strip()
|
||||
if term:
|
||||
like = f"%{term}%"
|
||||
statement = statement.where(
|
||||
or_(cls.position_title.ilike(like), cls.department.ilike(like))
|
||||
)
|
||||
limit = max(1, min(int(top or 50), 100))
|
||||
statement = statement.order_by(cls.created_at.desc(), cls.id.desc()).limit(limit)
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||
position = fields.get("position") if fields.get("position") else {}
|
||||
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
|
||||
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
|
||||
row = cls(
|
||||
department=position.get("department") if position.get("department") else None,
|
||||
position_title=position.get("title") if position.get("title") else None,
|
||||
date=position.get("date") if position.get("date") else None,
|
||||
date_needed=position.get("date_needed") if position.get("date_needed") else None,
|
||||
employment_type=EmploymentType(position.get("type")) if position.get("type") else None,
|
||||
job_description=position.get("job_description") if position.get("job_description") else None,
|
||||
employee_name=referral.get("employee_name") if referral.get("employee_name") else None,
|
||||
employee_department=referral.get("employee_department") if referral.get("employee_department") else None,
|
||||
to_replace=replacement.get("to_replace") if replacement.get("to_replace") else None,
|
||||
grade=replacement.get("grade") if replacement.get("grade") else None,
|
||||
recruitment_title=replacement.get("title") if replacement.get("title") else None,
|
||||
date_separated=replacement.get("date_separated") if replacement.get("date_separated") else None,
|
||||
justification=replacement.get("justification") if replacement.get("justification") else None,
|
||||
budget=replacement.get("budget") if replacement.get("budget") else None,
|
||||
recommended_grade=replacement.get("recommended_grade") if replacement.get("recommended_grade") else None,
|
||||
initiated_by=fields.get("initiated_by") if fields.get("initiated_by") else None,
|
||||
initiated_date=fields.get("initiated_date") if fields.get("initiated_date") else None,
|
||||
recommended_by=fields.get("recommended_by") if fields.get("recommended_by") else None,
|
||||
recommended_date=fields.get("recommended_date") if fields.get("recommended_date") else None,
|
||||
approved_by_hr=fields.get("approved_by_hr") if fields.get("approved_by_hr") is not None else None,
|
||||
approved_by_date_hr=fields.get("approved_by_date_hr") if fields.get("approved_by_date_hr") else None,
|
||||
approved_by_vp=fields.get("approved_by_vp") if fields.get("approved_by_vp") is not None else None,
|
||||
approved_by_date_vp=fields.get("approved_by_date_vp") if fields.get("approved_by_date_vp") else None,
|
||||
approved_by_svp=fields.get("approved_by_svp") if fields.get("approved_by_svp") is not None else None,
|
||||
approved_by_date_svp=fields.get("approved_by_date_svp") if fields.get("approved_by_date_svp") else None,
|
||||
created_by=fields.get("created_by") if fields.get("created_by") else None,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_form_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_form(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_form_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
if "position" in fields:
|
||||
position = fields.get("position") if fields.get("position") else {}
|
||||
if "department" in position:
|
||||
row.department = position.get("department") if position.get("department") else None
|
||||
if "title" in position:
|
||||
row.position_title = position.get("title") if position.get("title") else None
|
||||
if "date" in position:
|
||||
row.date = position.get("date") if position.get("date") else None
|
||||
if "date_needed" in position:
|
||||
row.date_needed = position.get("date_needed") if position.get("date_needed") else None
|
||||
if "type" in position:
|
||||
row.employment_type = EmploymentType(position.get("type")) if position.get("type") else None
|
||||
if "job_description" in position:
|
||||
row.job_description = position.get("job_description") if position.get("job_description") else None
|
||||
if "replacement_for" in fields:
|
||||
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
|
||||
if "to_replace" in replacement:
|
||||
row.to_replace = replacement.get("to_replace") if replacement.get("to_replace") else None
|
||||
if "grade" in replacement:
|
||||
row.grade = replacement.get("grade") if replacement.get("grade") else None
|
||||
if "title" in replacement:
|
||||
row.recruitment_title = replacement.get("title") if replacement.get("title") else None
|
||||
if "date_separated" in replacement:
|
||||
row.date_separated = replacement.get("date_separated") if replacement.get("date_separated") else None
|
||||
if "justification" in replacement:
|
||||
row.justification = replacement.get("justification") if replacement.get("justification") else None
|
||||
if "budget" in replacement:
|
||||
row.budget = replacement.get("budget") if replacement.get("budget") else None
|
||||
if "recommended_grade" in replacement:
|
||||
row.recommended_grade = replacement.get("recommended_grade") if replacement.get("recommended_grade") else None
|
||||
if "refferal_by" in fields:
|
||||
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
|
||||
if "employee_name" in referral:
|
||||
row.employee_name = referral.get("employee_name") if referral.get("employee_name") else None
|
||||
if "employee_department" in referral:
|
||||
row.employee_department = referral.get("employee_department") if referral.get("employee_department") else None
|
||||
if "initiated_by" in fields:
|
||||
row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None
|
||||
if "initiated_date" in fields:
|
||||
row.initiated_date = fields.get("initiated_date") if fields.get("initiated_date") else None
|
||||
if "recommended_by" in fields:
|
||||
row.recommended_by = fields.get("recommended_by") if fields.get("recommended_by") else None
|
||||
if "recommended_date" in fields:
|
||||
row.recommended_date = fields.get("recommended_date") if fields.get("recommended_date") else None
|
||||
if "approved_by_hr" in fields:
|
||||
row.approved_by_hr = fields.get("approved_by_hr") if fields.get("approved_by_hr") is not None else None
|
||||
if "approved_by_date_hr" in fields:
|
||||
row.approved_by_date_hr = fields.get("approved_by_date_hr") if fields.get("approved_by_date_hr") else None
|
||||
if "approved_by_vp" in fields:
|
||||
row.approved_by_vp = fields.get("approved_by_vp") if fields.get("approved_by_vp") is not None else None
|
||||
if "approved_by_date_vp" in fields:
|
||||
row.approved_by_date_vp = fields.get("approved_by_date_vp") if fields.get("approved_by_date_vp") else None
|
||||
if "approved_by_svp" in fields:
|
||||
row.approved_by_svp = fields.get("approved_by_svp") if fields.get("approved_by_svp") is not None else None
|
||||
if "approved_by_date_svp" in fields:
|
||||
row.approved_by_date_svp = fields.get("approved_by_date_svp") if fields.get("approved_by_date_svp") else None
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
class CandidateForms(SQLModel, table=True):
|
||||
"""One digitized hiring form (Annexure A requisition, or one of the two
|
||||
|
|
@ -100,8 +321,20 @@ class CandidateForms(SQLModel, table=True):
|
|||
return list(result.scalars().all()), total
|
||||
|
||||
@classmethod
|
||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(
|
||||
form_type=fields.get("form_type"),
|
||||
inbox_id=fields.get("inbox_id"),
|
||||
manual_upload_candidate_id=fields.get("manual_upload_candidate_id"),
|
||||
job_post_id=fields.get("job_post_id"),
|
||||
interviewer_id=fields.get("interviewer_id"),
|
||||
form_date=fields.get("form_date"),
|
||||
sections=fields.get("sections"),
|
||||
fields=fields.get("fields"),
|
||||
overall_score=fields.get("overall_score"),
|
||||
recommendation=fields.get("recommendation"),
|
||||
created_by=fields.get("created_by"),
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_form_by_id(session, row.id)
|
||||
|
|
@ -111,8 +344,18 @@ class CandidateForms(SQLModel, table=True):
|
|||
row = await cls.get_form_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
if "interviewer_id" in fields:
|
||||
row.interviewer_id = fields.get("interviewer_id")
|
||||
if "form_date" in fields:
|
||||
row.form_date = fields.get("form_date")
|
||||
if "sections" in fields:
|
||||
row.sections = fields.get("sections")
|
||||
if "fields" in fields:
|
||||
row.fields = fields.get("fields")
|
||||
if "overall_score" in fields:
|
||||
row.overall_score = fields.get("overall_score")
|
||||
if "recommendation" in fields:
|
||||
row.recommendation = fields.get("recommendation")
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
|
|
|
|||
|
|
@ -9,16 +9,17 @@ into every saved row so historical records survive future renames.
|
|||
|
||||
FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit")
|
||||
|
||||
RATING_MIN = 1
|
||||
RATING_MAX = 4
|
||||
RATING_POINTS = (25, 50, 75, 100)
|
||||
# Paper ticks used to be 1–4; coerce those to the matching percentage.
|
||||
_LEGACY_TICK = {1: 25, 2: 50, 3: 75, 4: 100}
|
||||
RATING_LABELS = {
|
||||
1: "Below Average (1)",
|
||||
2: "Average (2)",
|
||||
3: "Good (3)",
|
||||
4: "Excellent (4)",
|
||||
25: "Below Average (25%)",
|
||||
50: "Average (50%)",
|
||||
75: "Good (75%)",
|
||||
100: "Excellent (100%)",
|
||||
}
|
||||
RATING_SCALE_NOTE = (
|
||||
"Rating Scale: 1 = Below Average | 2 = Average | 3 = Good | 4 = Excellent. "
|
||||
"Rating Scale: Below Average = 25% | Average = 50% | Good = 75% | Excellent = 100%. "
|
||||
"Tick the box that applies for each criterion."
|
||||
)
|
||||
|
||||
|
|
@ -194,6 +195,7 @@ def definitions_payload() -> dict:
|
|||
"form_types": list(FORM_TYPES),
|
||||
"forms": FORM_DEFINITIONS,
|
||||
"rating_labels": {str(k): v for k, v in RATING_LABELS.items()},
|
||||
"rating_points": list(RATING_POINTS),
|
||||
"recommendations": list(RECOMMENDATIONS),
|
||||
"recommendation_labels": dict(RECOMMENDATION_LABELS),
|
||||
"employment_types": list(EMPLOYMENT_TYPES),
|
||||
|
|
@ -211,9 +213,10 @@ def _coerce_rating(value):
|
|||
raise ValueError(f"rating must be a number, got {value!r}")
|
||||
if number != int(number):
|
||||
raise ValueError(f"rating must be a whole number, got {value!r}")
|
||||
rating = int(number)
|
||||
if rating < RATING_MIN or rating > RATING_MAX:
|
||||
raise ValueError(f"rating must be between {RATING_MIN} and {RATING_MAX}, got {rating}")
|
||||
rating = _LEGACY_TICK.get(int(number), int(number))
|
||||
if rating not in RATING_POINTS:
|
||||
allowed = ", ".join(str(p) for p in RATING_POINTS)
|
||||
raise ValueError(f"rating must be one of {allowed}, got {rating}")
|
||||
return rating
|
||||
|
||||
|
||||
|
|
@ -224,15 +227,34 @@ def _mean(values, digits=2):
|
|||
return round(sum(values) / len(values), digits)
|
||||
|
||||
|
||||
def to_percent(score):
|
||||
"""Keep derived scores on 0–100.
|
||||
|
||||
New ticks are 25/50/75/100 and averages are already percentages. Legacy
|
||||
1–4 ticks or means (0, 4] convert once via (score / 4) × 100.
|
||||
"""
|
||||
if score is None:
|
||||
return None
|
||||
try:
|
||||
number = float(score)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if 0 < number <= 4:
|
||||
return round((number / 4) * 100, 2)
|
||||
return round(number, 2)
|
||||
|
||||
|
||||
def normalize_sections(form_type: str, sections):
|
||||
"""Validate submitted rated sections against the form definition and
|
||||
recompute all derived numbers. Returns (normalized_sections, overall_score).
|
||||
|
||||
Every definition section is emitted in definition order with denormalized
|
||||
labels; submitted per-criterion ratings are merged in; client-sent averages
|
||||
are discarded and recomputed (mean of the non-null ratings, 2 dp). The
|
||||
overall score is the mean of the section averages. Raises ValueError on
|
||||
unknown section/criterion keys or out-of-range ratings (422 material).
|
||||
are discarded and recomputed. Criterion ticks are 25/50/75/100. A section
|
||||
average is the mean of those percentages; the overall score is the mean of
|
||||
the section averages. Legacy 1–4 ticks are coerced to the matching percent
|
||||
before averaging. Raises ValueError on unknown section/criterion keys or
|
||||
ratings outside the scale (422 material).
|
||||
"""
|
||||
definition = FORM_DEFINITIONS.get(form_type)
|
||||
if definition is None:
|
||||
|
|
@ -278,7 +300,7 @@ def normalize_sections(form_type: str, sections):
|
|||
}
|
||||
for c in section_def["criteria"]
|
||||
]
|
||||
average = _mean([c["rating"] for c in criteria])
|
||||
average = to_percent(_mean([c["rating"] for c in criteria]))
|
||||
if average is not None:
|
||||
section_averages.append(average)
|
||||
normalized.append(
|
||||
|
|
@ -333,8 +355,10 @@ def combined_summary(rows):
|
|||
`rows` are candidate_forms records (attribute access: form_type, created_at,
|
||||
sections). The latest interview_analysis row supplies the technical and
|
||||
behavioral averages, the latest cultural_fit row the cultural average.
|
||||
The combined overall (mean of the three section averages, 2 dp) appears
|
||||
only once all three exist. Returns None when neither evaluation exists.
|
||||
The combined overall (mean of the three section averages, 2 dp, already
|
||||
ranged onto 0–100) appears only once all three exist. Returns None when
|
||||
neither evaluation exists. Legacy 1–4 section averages are converted
|
||||
through to_percent so mixed old/new rows stay comparable.
|
||||
"""
|
||||
latest = {}
|
||||
for row in rows:
|
||||
|
|
@ -351,7 +375,7 @@ def combined_summary(rows):
|
|||
for section in row.sections or []:
|
||||
key = section.get("key")
|
||||
if key in averages:
|
||||
averages[key] = section.get("average")
|
||||
averages[key] = to_percent(section.get("average"))
|
||||
|
||||
complete = all(v is not None for v in averages.values())
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,24 @@
|
|||
from candidate_forms.plugins import to_percent
|
||||
|
||||
|
||||
def _sections_as_percent(sections):
|
||||
if not sections:
|
||||
return list(sections) if sections else None
|
||||
out = []
|
||||
for section in sections:
|
||||
item = dict(section)
|
||||
if "average" in item:
|
||||
item["average"] = to_percent(item.get("average"))
|
||||
criteria = item.get("criteria")
|
||||
if criteria:
|
||||
item["criteria"] = [
|
||||
{**c, "rating": to_percent(c.get("rating"))} if isinstance(c, dict) else c
|
||||
for c in criteria
|
||||
]
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def serialize_form(
|
||||
row,
|
||||
*,
|
||||
|
|
@ -21,12 +42,74 @@ def serialize_form(
|
|||
"interviewer_id": str(row.interviewer_id) if row.interviewer_id else None,
|
||||
"interviewer_name": interviewer_name,
|
||||
"form_date": row.form_date.isoformat() if row.form_date else None,
|
||||
"sections": list(row.sections) if row.sections else None,
|
||||
"sections": _sections_as_percent(row.sections),
|
||||
"fields": dict(row.fields) if row.fields else {},
|
||||
"overall_score": row.overall_score,
|
||||
"overall_score": to_percent(row.overall_score),
|
||||
"recommendation": row.recommendation,
|
||||
"created_by": str(row.created_by) if row.created_by else None,
|
||||
"created_by_name": created_by_name,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _date(value):
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _enum(value):
|
||||
if value is None:
|
||||
return None
|
||||
return getattr(value, "value", value)
|
||||
|
||||
|
||||
def serialize_requisition_option(row) -> dict:
|
||||
"""Compact row for a searchable picker: `{job title} - {department}`."""
|
||||
title = (row.position_title or "").strip()
|
||||
department = (row.department or "").strip()
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"title": row.position_title,
|
||||
"department": row.department,
|
||||
"label": f"{title or 'Untitled'} - {department or '—'}",
|
||||
}
|
||||
|
||||
|
||||
def serialize_requisition(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"position": {
|
||||
"department": row.department,
|
||||
"title": row.position_title,
|
||||
"date": _date(row.date),
|
||||
"date_needed": _date(row.date_needed),
|
||||
"type": _enum(row.employment_type),
|
||||
"job_description": row.job_description,
|
||||
},
|
||||
"replacement_for": {
|
||||
"to_replace": row.to_replace,
|
||||
"grade": row.grade,
|
||||
"title": row.recruitment_title,
|
||||
"date_separated": _date(row.date_separated),
|
||||
"justification": row.justification,
|
||||
"budget": row.budget,
|
||||
"recommended_grade": row.recommended_grade,
|
||||
},
|
||||
"refferal_by": {
|
||||
"employee_name": row.employee_name,
|
||||
"employee_department": row.employee_department,
|
||||
},
|
||||
"initiated_by": row.initiated_by,
|
||||
"initiated_date": _date(row.initiated_date),
|
||||
"recommended_by": row.recommended_by,
|
||||
"recommended_date": _date(row.recommended_date),
|
||||
"approved_by_hr": row.approved_by_hr,
|
||||
"approved_by_date_hr": _date(row.approved_by_date_hr),
|
||||
"approved_by_vp": row.approved_by_vp,
|
||||
"approved_by_date_vp": _date(row.approved_by_date_vp),
|
||||
"approved_by_svp": row.approved_by_svp,
|
||||
"approved_by_date_svp": _date(row.approved_by_date_svp),
|
||||
"created_by": str(row.created_by) if row.created_by else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ from datetime import timezone
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from candidate_forms.models import CandidateForms, _now
|
||||
from candidate_forms.models import CandidateForms, Requisition, _now
|
||||
from candidate_forms.plugins import (
|
||||
FORM_DEFINITIONS,
|
||||
FORM_READY_STATUSES,
|
||||
FORM_TYPES,
|
||||
RECOMMENDATIONS,
|
||||
|
|
@ -14,13 +15,19 @@ from candidate_forms.plugins import (
|
|||
normalize_fields,
|
||||
normalize_sections,
|
||||
)
|
||||
from candidate_forms.serializers import serialize_form
|
||||
from candidate_forms.serializers import (
|
||||
serialize_form,
|
||||
serialize_requisition,
|
||||
serialize_requisition_option,
|
||||
)
|
||||
from inbox.models import Inbox
|
||||
from job.candidate.models import Interviews, Manual_UPLOAD_CANDIDATE
|
||||
from job.candidate.views import assert_manager_candidate_access
|
||||
from job.history.enums import HistoryEvent
|
||||
from job.history.views import HistoryRecorder
|
||||
from job.job_post.models import JobPosts
|
||||
from users.models import Users
|
||||
from users.permissions import is_admin, is_hiring_manager
|
||||
|
||||
logger = logging.getLogger("candidate_forms")
|
||||
|
||||
|
|
@ -53,6 +60,35 @@ def _stage_value(status) -> str:
|
|||
return str(getattr(status, "value", status) or "").upper()
|
||||
|
||||
|
||||
def _recommendation(form_type, value):
|
||||
definition = FORM_DEFINITIONS.get(form_type) or {}
|
||||
if not definition.get("has_recommendation"):
|
||||
return None
|
||||
if value in (None, ""):
|
||||
return None
|
||||
value = str(value).strip()
|
||||
if value not in RECOMMENDATIONS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _score_sections(form_type, sections):
|
||||
try:
|
||||
return normalize_sections(form_type, sections)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
||||
def _score_fields(form_type, fields):
|
||||
try:
|
||||
return normalize_fields(form_type, fields)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
||||
class CandidateForm:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
|
@ -80,55 +116,28 @@ class CandidateForm:
|
|||
stage = _stage_value(
|
||||
link.messages.application_status if link.messages is not None else None
|
||||
)
|
||||
app_job = (
|
||||
link.messages.assigned_job_post_id if link.messages is not None else None
|
||||
)
|
||||
else:
|
||||
inbox_id = None
|
||||
manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id)
|
||||
if manual is None:
|
||||
raise HTTPException(status_code=404, detail="Manual upload candidate not found")
|
||||
stage = _stage_value(manual.status)
|
||||
app_job = manual.job_post_id
|
||||
|
||||
job_post_id = _as_uuid(payload.get("job_post_id"))
|
||||
if payload.get("job_post_id") and job_post_id is None:
|
||||
raise HTTPException(status_code=422, detail="Invalid job_post_id")
|
||||
if job_post_id is None:
|
||||
job_post_id = app_job
|
||||
if job_post_id is not None:
|
||||
post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id))
|
||||
if not post or post.is_deleted:
|
||||
raise HTTPException(status_code=404, detail="Job post not found")
|
||||
return inbox_id, manual_id, job_post_id, stage
|
||||
|
||||
def _normalize_payload(self, form_type, payload):
|
||||
"""Shared create/update normalization. Returns the writable fields dict
|
||||
for the keys present in `payload`."""
|
||||
fields = {}
|
||||
if "sections" in payload:
|
||||
try:
|
||||
sections, overall = normalize_sections(form_type, payload.get("sections"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
fields["sections"] = sections
|
||||
fields["overall_score"] = overall
|
||||
if "fields" in payload:
|
||||
try:
|
||||
fields["fields"] = normalize_fields(form_type, payload.get("fields"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
if "recommendation" in payload:
|
||||
recommendation = payload.get("recommendation") or None
|
||||
if recommendation is not None and recommendation not in RECOMMENDATIONS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
|
||||
)
|
||||
fields["recommendation"] = recommendation
|
||||
if "interviewer_id" in payload:
|
||||
interviewer_id = _as_uuid(payload.get("interviewer_id"))
|
||||
if payload.get("interviewer_id") and interviewer_id is None:
|
||||
raise HTTPException(status_code=422, detail="Invalid interviewer_id")
|
||||
fields["interviewer_id"] = interviewer_id
|
||||
if "form_date" in payload:
|
||||
fields["form_date"] = _aware(payload.get("form_date"))
|
||||
return fields
|
||||
|
||||
async def _context_maps(self, rows):
|
||||
inbox_ids = [r.inbox_id for r in rows if r.inbox_id is not None]
|
||||
manual_ids = [r.manual_upload_candidate_id for r in rows if r.manual_upload_candidate_id]
|
||||
|
|
@ -213,11 +222,27 @@ class CandidateForm:
|
|||
form_type=None,
|
||||
top=None,
|
||||
skip=0,
|
||||
current_user=None,
|
||||
):
|
||||
if form_type and form_type not in FORM_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}"
|
||||
)
|
||||
if is_hiring_manager(current_user) and not (
|
||||
form_id or inbox_id is not None or manual_upload_candidate_id or job_post_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Hiring managers can only load forms for candidates on their requisitions",
|
||||
)
|
||||
if inbox_id is not None or manual_upload_candidate_id is not None or job_post_id:
|
||||
await assert_manager_candidate_access(
|
||||
self.session,
|
||||
current_user,
|
||||
job_post_id=job_post_id,
|
||||
inbox_id=inbox_id,
|
||||
manual_id=manual_upload_candidate_id,
|
||||
)
|
||||
rows, total = await CandidateForms.fetch_forms(
|
||||
self.session,
|
||||
form_id=form_id,
|
||||
|
|
@ -228,6 +253,14 @@ class CandidateForm:
|
|||
top=top,
|
||||
skip=skip or 0,
|
||||
)
|
||||
if form_id and rows:
|
||||
await assert_manager_candidate_access(
|
||||
self.session,
|
||||
current_user,
|
||||
job_post_id=rows[0].job_post_id,
|
||||
inbox_id=rows[0].inbox_id,
|
||||
manual_id=rows[0].manual_upload_candidate_id,
|
||||
)
|
||||
|
||||
summary = None
|
||||
if inbox_id is not None or manual_upload_candidate_id is not None:
|
||||
|
|
@ -250,11 +283,14 @@ class CandidateForm:
|
|||
status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}"
|
||||
)
|
||||
inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload)
|
||||
await assert_manager_candidate_access(
|
||||
self.session,
|
||||
current_user,
|
||||
job_post_id=job_post_id,
|
||||
inbox_id=inbox_id,
|
||||
manual_id=manual_id,
|
||||
)
|
||||
if stage not in FORM_READY_STATUSES:
|
||||
# A scheduled interview also unlocks the forms: the paperwork belongs
|
||||
# to the interview, not to which kanban column the card sits in.
|
||||
# (Interview records link only to inbox rows, so manual-upload
|
||||
# candidates unlock by stage alone.)
|
||||
has_interview = False
|
||||
if inbox_id is not None:
|
||||
rows = await Interviews.get_interviews_by_inbox(self.session, inbox_id)
|
||||
|
|
@ -269,28 +305,29 @@ class CandidateForm:
|
|||
),
|
||||
)
|
||||
|
||||
fields = {
|
||||
"inbox_id": inbox_id,
|
||||
"manual_upload_candidate_id": manual_id,
|
||||
"job_post_id": job_post_id,
|
||||
"form_type": form_type,
|
||||
"created_by": _user_id(current_user),
|
||||
}
|
||||
fields.update(
|
||||
self._normalize_payload(
|
||||
form_type,
|
||||
{
|
||||
key: payload.get(key)
|
||||
for key in ("sections", "fields", "recommendation", "interviewer_id", "form_date")
|
||||
},
|
||||
)
|
||||
)
|
||||
if form_type != "requisition" and fields.get("interviewer_id") is None:
|
||||
fields["interviewer_id"] = _user_id(current_user)
|
||||
if fields.get("form_date") is None:
|
||||
fields["form_date"] = _now()
|
||||
interviewer_id = _as_uuid(payload.get("interviewer_id"))
|
||||
if form_type != "requisition" and interviewer_id is None:
|
||||
interviewer_id = _user_id(current_user)
|
||||
form_date = _aware(payload.get("form_date")) or _now()
|
||||
sections, overall_score = _score_sections(form_type, payload.get("sections"))
|
||||
fields = _score_fields(form_type, payload.get("fields"))
|
||||
|
||||
row = await CandidateForms.insert_form(self.session, fields)
|
||||
row = await CandidateForms.insert_form(
|
||||
self.session,
|
||||
{
|
||||
"form_type": form_type,
|
||||
"inbox_id": inbox_id,
|
||||
"manual_upload_candidate_id": manual_id,
|
||||
"job_post_id": job_post_id,
|
||||
"interviewer_id": interviewer_id,
|
||||
"form_date": form_date,
|
||||
"sections": sections,
|
||||
"fields": fields,
|
||||
"overall_score": overall_score,
|
||||
"recommendation": _recommendation(form_type, payload.get("recommendation")),
|
||||
"created_by": _user_id(current_user),
|
||||
},
|
||||
)
|
||||
await HistoryRecorder(self.session).record(
|
||||
HistoryEvent.FORM_CREATED,
|
||||
current_user=current_user,
|
||||
|
|
@ -308,8 +345,27 @@ class CandidateForm:
|
|||
row = await CandidateForms.get_form_by_id(self.session, form_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
await assert_manager_candidate_access(
|
||||
self.session,
|
||||
current_user,
|
||||
job_post_id=row.job_post_id,
|
||||
inbox_id=row.inbox_id,
|
||||
manual_id=row.manual_upload_candidate_id,
|
||||
)
|
||||
|
||||
fields = self._normalize_payload(row.form_type, payload)
|
||||
fields = {}
|
||||
if "interviewer_id" in payload:
|
||||
fields["interviewer_id"] = _as_uuid(payload.get("interviewer_id"))
|
||||
if "form_date" in payload:
|
||||
fields["form_date"] = _aware(payload.get("form_date"))
|
||||
if "sections" in payload:
|
||||
sections, overall_score = _score_sections(row.form_type, payload.get("sections"))
|
||||
fields["sections"] = sections
|
||||
fields["overall_score"] = overall_score
|
||||
if "fields" in payload:
|
||||
fields["fields"] = _score_fields(row.form_type, payload.get("fields"))
|
||||
if "recommendation" in payload:
|
||||
fields["recommendation"] = _recommendation(row.form_type, payload.get("recommendation"))
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
|
|
@ -330,7 +386,57 @@ class CandidateForm:
|
|||
|
||||
async def delete_form(self, form_id, current_user):
|
||||
_user_id(current_user)
|
||||
row = await CandidateForms.get_form_by_id(self.session, form_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
await assert_manager_candidate_access(
|
||||
self.session,
|
||||
current_user,
|
||||
job_post_id=row.job_post_id,
|
||||
inbox_id=row.inbox_id,
|
||||
manual_id=row.manual_upload_candidate_id,
|
||||
)
|
||||
row = await CandidateForms.soft_delete_form(self.session, form_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
return {"id": str(row.id), "deleted": True}
|
||||
|
||||
class RequisitionForm:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def create_form(self, payload, current_user):
|
||||
payload["created_by"] = _user_id(current_user)
|
||||
row = await Requisition.insert_form(self.session, payload)
|
||||
return serialize_requisition(row)
|
||||
|
||||
async def update_form(self, form_id, payload, current_user):
|
||||
_user_id(current_user)
|
||||
row = await Requisition.get_form_by_id(self.session, form_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
if not payload:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
updated = await Requisition.update_form(self.session, form_id, payload)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
return serialize_requisition(updated)
|
||||
|
||||
|
||||
async def get_form_by_id(self, form_id, current_user):
|
||||
# Admins see the full table. Managers still only see rows they opened.
|
||||
# Job-post linkage is ignored here — that filter is search/picker only.
|
||||
created_by = None if is_admin(current_user) else _user_id(current_user)
|
||||
if form_id:
|
||||
row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
return serialize_requisition(row)
|
||||
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
|
||||
return [serialize_requisition(r) for r in rows]
|
||||
|
||||
async def search(self, q, top=50, job_post_id=None):
|
||||
rows = await Requisition.search(
|
||||
self.session, q, top=top, job_post_id=job_post_id,
|
||||
)
|
||||
return [serialize_requisition_option(r) for r in rows]
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
"refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8",
|
||||
"universe_domain": "googleapis.com",
|
||||
"account": "ahmed.mujtaba@utopiabrands.com",
|
||||
"token": "ya29.a0AdMD6Eg_6meQs84gTmiyhzZp7C-JlZeJU6-ECm6twwAcMqvfvRvyvs5LQGbAhHarHzZF-jiU-sicebJmxXIN4l6hNDXoaHcrojuhq--hj2oSBWojiEKaGIgLKPM8frdspz_wVANrwkFwpIKhN3RpWID9mJCt7N6IFaNrZtgStakdF0sVCKKVttE7qWK0vIvJT3HZHpVbaCgYKAX8SARASFQHGX2MiQvJqWStYQDgYFK4E6GJ9Zw0207",
|
||||
"expiry": "2026-08-31T09:52:09Z",
|
||||
"token": "ya29.a0AdMD6EgILeNb9UszC7bQJbAcqX709J5ky3eM8MEuQayGwhDStmfnR5t7o192x-FPdt53Q29rYL69zqYrgofUqpwxoI_sPjBsb0wrLqYDo6zwJMTx4P5svM4jZJd9nrXzUEyp3uI81e9DQ3z6lIDKtm6aUTPWQ3fm33i2hxJM7i-svhi3OwjnLtpOUHDyw--v8rQLPHdfaCgYKATkSARASFQHGX2MiXDnutGLllH9DnNCBUKWi4Q0207",
|
||||
"expiry": "2026-09-02T08:29:45Z",
|
||||
"quota_project_id": "hrms-ats-portal"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class Inbox(SQLModel, table=True):
|
|||
)
|
||||
|
||||
@classmethod
|
||||
async def get_all(cls,session:AsyncSession,job_post_id=None,limit=None,offset=0):
|
||||
async def get_all(cls,session:AsyncSession,job_post_id=None,job_post_ids=None,limit=None,offset=0):
|
||||
try:
|
||||
from job.job_post.models import JobPosts
|
||||
qry=(
|
||||
|
|
@ -116,7 +116,12 @@ class Inbox(SQLModel, table=True):
|
|||
cls.id.desc(),
|
||||
)
|
||||
)
|
||||
if job_post_id:
|
||||
if job_post_ids is not None:
|
||||
ids=list(job_post_ids)
|
||||
if not ids:
|
||||
return []
|
||||
qry=qry.where(Inbox_Messages.assigned_job_post_id.in_(ids))
|
||||
elif job_post_id:
|
||||
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
|
||||
if limit is not None:
|
||||
qry=qry.limit(limit).offset(offset)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from job.history.views import HistoryRecorder
|
|||
from job.assignment.views import Assignment
|
||||
from job.cost.views import HiringCost
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
from users.permissions import PermissionTag, is_hiring_manager, require_permission
|
||||
from job.job_post.views import JobPost,JobPostCreate
|
||||
from job_assist.execute_agent import run_field_assist
|
||||
from job.job_post.export import build_jobs_workbook
|
||||
|
|
@ -140,6 +140,7 @@ class JobUpdate(BaseModel):
|
|||
description: str | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
hiring_manager_id: UUID | None = None
|
||||
requisition_id: UUID | None = None
|
||||
|
||||
|
||||
class JobStatusUpdate(BaseModel):
|
||||
|
|
@ -235,9 +236,16 @@ async def fetch_users(
|
|||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
if is_hiring_manager(current_user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Hiring managers can only list candidates on their requisitions",
|
||||
)
|
||||
service=User(session=session)
|
||||
data=await service.get_users(role_id=role_id,top=top,skip=skip)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
|
@ -251,9 +259,16 @@ async def count_candidate_users(
|
|||
):
|
||||
"""Total matching users for the Candidates pager. Called once on page open."""
|
||||
try:
|
||||
if is_hiring_manager(current_user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Hiring managers can only list candidates on their requisitions",
|
||||
)
|
||||
service=User(session=session)
|
||||
total=await service.count_users(search=search,role_id=role_id)
|
||||
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
|
@ -727,6 +742,46 @@ async def fetch_job_posts(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/job/stats/fetch")
|
||||
async def fetch_job_stats(
|
||||
job_post_id: Optional[uuid.UUID] = Query(None),
|
||||
search: str | None = Query(None),
|
||||
ids: str | None = Query(None),
|
||||
top: int | None = Query(10, ge=1, le=500),
|
||||
skip: int = Query(0, ge=0),
|
||||
active_only: bool = Query(False),
|
||||
current_user: dict = Depends(
|
||||
require_permission(
|
||||
PermissionTag.JOBS_VIEW,
|
||||
PermissionTag.PIPELINE_VIEW,
|
||||
require_all=False,
|
||||
)
|
||||
),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Live pipeline-stage counts per job post (inbox + manual upload).
|
||||
|
||||
Omit job_post_id for a paged list (optional search / ids). Pass job_post_id
|
||||
for a single object. Counts are aggregated, not stored.
|
||||
"""
|
||||
try:
|
||||
service=JobPost(session=session)
|
||||
id_list=[x.strip() for x in (ids or "").split(",") if x.strip()] or None
|
||||
data,total=await service.fetch_job_stats(
|
||||
job_post_id=job_post_id,
|
||||
search=search,
|
||||
ids=id_list,
|
||||
top=top,
|
||||
skip=skip,
|
||||
active_only=active_only,
|
||||
)
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/job/departments/fetch")
|
||||
async def fetch_job_departments(
|
||||
active_only: bool = Query(False),
|
||||
|
|
@ -864,6 +919,25 @@ async def fetch_candidate_by_id(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/candidate/manager/fetch")
|
||||
async def fetch_manager_candidates(
|
||||
limit:int=Query(50,ge=1,le=200),
|
||||
offset:int=Query(0,ge=0),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Candidates allocated to jobs opened from this user's requisitions
|
||||
(or where they are the assigned hiring manager)."""
|
||||
try:
|
||||
service=CandidateView(session=session)
|
||||
data,total=await service.list_manager_candidates(current_user,limit=limit,offset=offset)
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/candidate/fetch")
|
||||
async def fetch_candidate(
|
||||
user_id:str=Query(None),
|
||||
|
|
@ -875,7 +949,9 @@ async def fetch_candidate(
|
|||
):
|
||||
try:
|
||||
service=CandidateView(session=session)
|
||||
data=await service.get_candidate(user_id=user_id,limit=limit,offset=offset,search=search)
|
||||
data=await service.get_candidate(
|
||||
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1
|
||||
|
|
@ -998,7 +1074,7 @@ async def fetch_notes(
|
|||
):
|
||||
try:
|
||||
service=Note(session=session)
|
||||
data=await service.get_note(note_id=note_id,user_id=user_id)
|
||||
data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user)
|
||||
total=1 if isinstance(data,dict) else len(data)
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -50,9 +50,8 @@ class Assignment:
|
|||
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.
|
||||
user_id None = unassign. 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)
|
||||
|
|
@ -63,8 +62,6 @@ class Assignment:
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
|
||||
@classmethod
|
||||
async def get_all(cls, session: AsyncSession, job_post_id=None, limit=None, offset=0):
|
||||
async def get_all(cls, session: AsyncSession, job_post_id=None, job_post_ids=None, limit=None, offset=0):
|
||||
try:
|
||||
from inbox.models import AtsResults
|
||||
from users.models import Users
|
||||
|
|
@ -111,7 +111,12 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
cls.id.desc(),
|
||||
)
|
||||
)
|
||||
if job_post_id:
|
||||
if job_post_ids is not None:
|
||||
ids=list(job_post_ids)
|
||||
if not ids:
|
||||
return []
|
||||
qry=qry.where(cls.job_post_id.in_(ids))
|
||||
elif job_post_id:
|
||||
qry=qry.where(cls.job_post_id==job_post_id)
|
||||
if limit is not None:
|
||||
qry=qry.limit(limit).offset(offset)
|
||||
|
|
|
|||
|
|
@ -220,3 +220,24 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
|||
"summary_critique": None,
|
||||
"scored_at": None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_manager_candidate(row, *, source) -> dict:
|
||||
"""One application on a hiring-manager's job — list row, not the profile."""
|
||||
inbox_id = row.get("inbox_id")
|
||||
manual_id = row.get("id") if source == "manual" else None
|
||||
job_post_id = row.get("assigned_job_post_id") or row.get("job_post_id")
|
||||
user_id = row.get("user_id")
|
||||
return {
|
||||
"id": user_id or (f"inbox:{inbox_id}" if inbox_id is not None else f"manual:{manual_id}"),
|
||||
"user_id": user_id,
|
||||
"name": row.get("name"),
|
||||
"email": row.get("email") or row.get("candidate_email"),
|
||||
"job_post_id": job_post_id,
|
||||
"job_title": row.get("title"),
|
||||
"application_status": row.get("application_status"),
|
||||
"inbox_id": inbox_id,
|
||||
"manual_upload_candidate_id": str(manual_id) if manual_id else None,
|
||||
"created_at": row.get("created_at"),
|
||||
"source": source,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from job.candidate.plugins import (
|
|||
get_scoring_settings,
|
||||
normalize_spaced_text,
|
||||
)
|
||||
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate
|
||||
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
|
||||
|
|
@ -32,6 +32,7 @@ from job.history.views import HistoryRecorder
|
|||
from job.notes.serializers import serialize_note
|
||||
from job.candidate.plugins import extract_candidate_email
|
||||
from users.models import Users
|
||||
from users.permissions import is_hiring_manager
|
||||
from employment_agent.plugins import parse_phone
|
||||
|
||||
load_dotenv()
|
||||
|
|
@ -40,6 +41,62 @@ CV_QUEUE_NAME=os.getenv("TASKIQ_CV_QUEUE_NAME","cv_upload")
|
|||
MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
|
||||
"MANUAL_UPLOAD_TO_ADDRESS","manual-cv-upload@hr-ats.local"
|
||||
)
|
||||
MANAGER_SCOPE_DETAIL="You can only access candidates allocated to jobs opened from your requisitions"
|
||||
|
||||
|
||||
async def assigned_job_ids_for_user(session,user_id):
|
||||
"""Job posts this candidate is allocated to (inbox assignment + manual upload)."""
|
||||
ids=set()
|
||||
if not user_id:
|
||||
return ids
|
||||
rows=await Inbox.get_candidate_profile(session=session,user_id=user_id,limit=1000,offset=0)
|
||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||
for rec in records:
|
||||
msg=getattr(rec,"messages",None)
|
||||
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
|
||||
if jid:
|
||||
ids.add(jid)
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(session,user_id)
|
||||
if manual and manual.job_post_id:
|
||||
ids.add(manual.job_post_id)
|
||||
return ids
|
||||
|
||||
|
||||
async def job_id_for_application(session,inbox_id=None,manual_id=None):
|
||||
if inbox_id is not None:
|
||||
link=await Inbox.get_inbox_with_message(session,inbox_id)
|
||||
if link is None:
|
||||
return None,None
|
||||
msg=link.messages
|
||||
return (msg.assigned_job_post_id if msg is not None else None),link.user_id
|
||||
if manual_id is not None:
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_id(session,manual_id)
|
||||
if manual is None:
|
||||
return None,None
|
||||
return manual.job_post_id,manual.user_id
|
||||
return None,None
|
||||
|
||||
|
||||
async def assert_manager_candidate_access(
|
||||
session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None,
|
||||
):
|
||||
"""Hiring managers may only touch applications on jobs they own."""
|
||||
if not is_hiring_manager(current_user):
|
||||
return
|
||||
owned=set(await JobPosts.ids_for_manager(session,current_user.get("id")))
|
||||
if not owned:
|
||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
||||
job_id=JobPosts._as_uuid(job_post_id) if job_post_id is not None else None
|
||||
uid=user_id
|
||||
if job_id is None and (inbox_id is not None or manual_id is not None):
|
||||
job_id,uid=await job_id_for_application(session,inbox_id=inbox_id,manual_id=manual_id)
|
||||
if job_id is None and uid is not None:
|
||||
candidate_jobs=await assigned_job_ids_for_user(session,uid)
|
||||
if candidate_jobs & owned:
|
||||
return
|
||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
||||
if job_id is None or job_id not in owned:
|
||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
||||
|
||||
|
||||
async def parse_linkedin_url_from_cv(resume_text) -> str | None:
|
||||
|
|
@ -697,14 +754,62 @@ class CandidateView:
|
|||
logger.exception("manual candidate rollback failed for %s",getattr(row,"id",None))
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None):
|
||||
async def list_manager_candidates(self,current_user,limit=50,offset=0):
|
||||
"""Candidates allocated to jobs this manager owns (requisition → job post)."""
|
||||
job_ids=await JobPosts.ids_for_manager(self.session,current_user.get("id"))
|
||||
if not job_ids:
|
||||
return [],0
|
||||
inbox_rows=await Inbox.get_all(self.session,job_post_ids=job_ids)
|
||||
manual_rows=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_ids=job_ids)
|
||||
merged=[]
|
||||
seen=set()
|
||||
for row in inbox_rows:
|
||||
uid=row.get("user_id")
|
||||
payload=serialize_manager_candidate(row,source="inbox")
|
||||
if uid and uid not in seen:
|
||||
seen.add(uid)
|
||||
merged.append(payload)
|
||||
elif not uid:
|
||||
merged.append(payload)
|
||||
for row in manual_rows:
|
||||
uid=row.get("user_id")
|
||||
if uid and uid in seen:
|
||||
continue
|
||||
payload=serialize_manager_candidate(row,source="manual")
|
||||
if uid:
|
||||
seen.add(uid)
|
||||
merged.append(payload)
|
||||
merged.sort(key=lambda r: r.get("created_at") or "",reverse=True)
|
||||
total=len(merged)
|
||||
start=max(0,int(offset or 0))
|
||||
cap=max(1,int(limit or 50))
|
||||
return merged[start:start+cap],total
|
||||
|
||||
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None):
|
||||
try:
|
||||
if not user_id and is_hiring_manager(current_user):
|
||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
||||
if user_id and is_hiring_manager(current_user):
|
||||
await assert_manager_candidate_access(
|
||||
self.session,current_user,user_id=user_id,
|
||||
)
|
||||
detail=bool(user_id)
|
||||
# Detail mode must see every application for the candidate, not one page.
|
||||
fetch_limit=1000 if detail else limit
|
||||
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search)
|
||||
if detail:
|
||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||
if records and is_hiring_manager(current_user):
|
||||
owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id")))
|
||||
kept=[]
|
||||
for rec in records:
|
||||
msg=getattr(rec,"messages",None)
|
||||
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
|
||||
if jid and jid in owned:
|
||||
kept.append(rec)
|
||||
if kept:
|
||||
return await self.attach_profile_detail(kept)
|
||||
records=[]
|
||||
if records:
|
||||
return await self.attach_profile_detail(rows)
|
||||
# Manual uploads create users + manual_upload_candidate but no inbox
|
||||
|
|
@ -712,6 +817,10 @@ class CandidateView:
|
|||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||
if not manual:
|
||||
return []
|
||||
if is_hiring_manager(current_user):
|
||||
owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id")))
|
||||
if not manual.job_post_id or manual.job_post_id not in owned:
|
||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
||||
user=await Users.get_user_by_id(self.session,user_id)
|
||||
job_post=None
|
||||
if manual.job_post_id:
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, JSON, Index, func, or_
|
||||
from sqlalchemy import DateTime, JSON, Index, String, case, cast, func, or_, union_all
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
from job.job_post.enums import RequisitionStatus
|
||||
|
||||
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
||||
from candidate_forms.models import Requisition
|
||||
from users.models import Users
|
||||
|
||||
|
||||
|
|
@ -22,12 +24,7 @@ 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 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]"},
|
||||
|
|
@ -51,9 +48,7 @@ class JobPosts(SQLModel, table=True):
|
|||
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
status: str = Field(default="draft")
|
||||
buffer_error: str | None = Field(default=None)
|
||||
# requisition_status is the hiring lifecycle (RequisitionStatus). Distinct from
|
||||
# `status`, which tracks Buffer publishing (draft/scheduled/published/failed).
|
||||
# server_default is load-bearing: this column arrives as an ALTER on a populated table.
|
||||
|
||||
requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"})
|
||||
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
||||
|
|
@ -61,9 +56,19 @@ class JobPosts(SQLModel, table=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
|
||||
# Who owns the requisition (stable). Optional. 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)
|
||||
# Annexure A employee requisition this job was opened from (optional 1:1).
|
||||
# Distinct from requisition_status, which is the hiring lifecycle on this row.
|
||||
# unique=True so two job posts cannot share one requisition; NULLs stay allowed.
|
||||
requisition_id: uuid.UUID | None = Field(
|
||||
default=None, foreign_key="requisitions.id", ondelete="SET NULL", unique=True, index=True,
|
||||
)
|
||||
requisition: Optional["Requisition"] = Relationship(
|
||||
back_populates="job_post",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
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))
|
||||
|
|
@ -83,6 +88,20 @@ class JobPosts(SQLModel, table=True):
|
|||
result = await session.execute(select(cls).where(cls.id == uid))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_by_requisition_id(cls, session: AsyncSession, requisition_id):
|
||||
"""Live job post already opened from this Annexure A requisition, if any."""
|
||||
uid = cls._as_uuid(requisition_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(cls).where(
|
||||
cls.requisition_id == uid,
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_active_job_posts(cls, session: AsyncSession):
|
||||
result = await session.execute(
|
||||
|
|
@ -177,6 +196,193 @@ class JobPosts(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
@classmethod
|
||||
async def fetch_job_stats(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
job_post_id=None,
|
||||
search: str | None = None,
|
||||
ids: list[str] | None = None,
|
||||
top: int | None = None,
|
||||
skip: int = 0,
|
||||
active_only: bool = False,
|
||||
):
|
||||
"""Per-job pipeline stage counts for every applicant assigned to the job.
|
||||
|
||||
Inbox, manual-upload / Add Candidate / CV-bank, and unpromoted sheet
|
||||
rows. Duplicate emails (case-insensitive) count once per job — the
|
||||
furthest pipeline stage is kept. Flagged is_duplicate rows are skipped.
|
||||
Rows with no email still count, each as themselves. Jobs with zero
|
||||
applicants still appear (LEFT JOIN).
|
||||
"""
|
||||
from g_sheet.models import FormData
|
||||
from inbox.models import Inbox_Messages
|
||||
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||
from users.models import Users
|
||||
|
||||
job_uids = []
|
||||
if job_post_id is not None:
|
||||
uid = job_post_id if isinstance(job_post_id, uuid.UUID) else cls._as_uuid(job_post_id)
|
||||
if uid is None:
|
||||
return [], 0
|
||||
job_uids = [uid]
|
||||
elif ids:
|
||||
for raw in ids:
|
||||
uid = cls._as_uuid(raw)
|
||||
if uid is not None:
|
||||
job_uids.append(uid)
|
||||
if not job_uids:
|
||||
return [], 0
|
||||
|
||||
def dup_key(email_col, row_id):
|
||||
# Same person = lower(trim(email)). No address -> unique per row
|
||||
# so blank emails do not collapse into one applicant.
|
||||
return func.coalesce(
|
||||
func.nullif(func.lower(func.btrim(email_col)), ""),
|
||||
func.concat("noid:", cast(row_id, String)),
|
||||
)
|
||||
|
||||
inbox_q = (
|
||||
select(
|
||||
Inbox_Messages.assigned_job_post_id.label("job_post_id"),
|
||||
dup_key(Inbox_Messages.message_from, Inbox_Messages.id).label("dup_key"),
|
||||
cast(Inbox_Messages.application_status, String).label("stage"),
|
||||
)
|
||||
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
||||
.where(Inbox_Messages.is_duplicate == False) # noqa: E712
|
||||
)
|
||||
manual_stage = func.coalesce(
|
||||
func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.status), ""),
|
||||
"PENDING",
|
||||
)
|
||||
manual_email = func.coalesce(
|
||||
func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.candidate_email), ""),
|
||||
Users.email,
|
||||
)
|
||||
manual_q = (
|
||||
select(
|
||||
Manual_UPLOAD_CANDIDATE.job_post_id.label("job_post_id"),
|
||||
dup_key(manual_email, Manual_UPLOAD_CANDIDATE.id).label("dup_key"),
|
||||
manual_stage.label("stage"),
|
||||
)
|
||||
.select_from(Manual_UPLOAD_CANDIDATE)
|
||||
.outerjoin(Users, Users.id == Manual_UPLOAD_CANDIDATE.user_id)
|
||||
.where(Manual_UPLOAD_CANDIDATE.job_post_id.is_not(None))
|
||||
)
|
||||
# Unpromoted sheet applicants only — promoted rows already live on
|
||||
# manual_upload_candidate (manual_upload_candidate_id set).
|
||||
form_stage = case(
|
||||
(FormData.processing_state == "rejected", "REJECTED"),
|
||||
else_="PENDING",
|
||||
)
|
||||
form_q = (
|
||||
select(
|
||||
FormData.job_post_id.label("job_post_id"),
|
||||
dup_key(FormData.candidate_email, FormData.id).label("dup_key"),
|
||||
form_stage.label("stage"),
|
||||
)
|
||||
.where(FormData.job_post_id.is_not(None))
|
||||
.where(FormData.manual_upload_candidate_id.is_(None))
|
||||
.where(FormData.is_duplicate == False) # noqa: E712
|
||||
)
|
||||
if job_uids:
|
||||
inbox_q = inbox_q.where(Inbox_Messages.assigned_job_post_id.in_(job_uids))
|
||||
manual_q = manual_q.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids))
|
||||
form_q = form_q.where(FormData.job_post_id.in_(job_uids))
|
||||
|
||||
apps = union_all(inbox_q, manual_q, form_q).subquery("applications")
|
||||
stage_rank = case(
|
||||
(apps.c.stage == "HIRED", 9),
|
||||
(apps.c.stage == "APPROVED", 8),
|
||||
(apps.c.stage == "OFFER", 7),
|
||||
(apps.c.stage == "INTERVIEW", 6),
|
||||
(apps.c.stage == "ASSESSMENT", 5),
|
||||
(apps.c.stage.in_(["SCREENING", "PROCESS"]), 4),
|
||||
(apps.c.stage == "PENDING", 3),
|
||||
(apps.c.stage == "ONHOLD", 2),
|
||||
(apps.c.stage.in_(["REJECTED", "CLOSED"]), 1),
|
||||
else_=0,
|
||||
)
|
||||
unique_apps = (
|
||||
select(apps.c.job_post_id, apps.c.dup_key, apps.c.stage)
|
||||
.distinct(apps.c.job_post_id, apps.c.dup_key)
|
||||
.order_by(apps.c.job_post_id, apps.c.dup_key, stage_rank.desc())
|
||||
.subquery("unique_applicants")
|
||||
)
|
||||
stage = unique_apps.c.stage
|
||||
|
||||
def stage_count(*values):
|
||||
return func.coalesce(func.sum(case((stage.in_(list(values)), 1), else_=0)), 0)
|
||||
|
||||
stats = (
|
||||
select(
|
||||
unique_apps.c.job_post_id,
|
||||
func.count().label("total_applicants"),
|
||||
stage_count("PENDING").label("shortlisting"),
|
||||
stage_count("SCREENING", "PROCESS").label("screened"),
|
||||
stage_count("ASSESSMENT").label("assessment"),
|
||||
stage_count("INTERVIEW").label("interviewed"),
|
||||
stage_count("OFFER").label("offered"),
|
||||
stage_count("ONHOLD").label("on_hold"),
|
||||
stage_count("REJECTED", "CLOSED").label("rejected"),
|
||||
stage_count("APPROVED").label("approved"),
|
||||
stage_count("HIRED").label("hired"),
|
||||
)
|
||||
.select_from(unique_apps)
|
||||
.group_by(unique_apps.c.job_post_id)
|
||||
.subquery("job_stage_stats")
|
||||
)
|
||||
|
||||
# Alias so this join does not collide with the Users join inside
|
||||
# the manual-upload subquery above.
|
||||
Recruiter=aliased(Users)
|
||||
statement = (
|
||||
select(
|
||||
cls.id.label("job_post_id"),
|
||||
cls.title,
|
||||
cls.department,
|
||||
cls.location,
|
||||
cls.requisition_status,
|
||||
cls.current_recruiter_id,
|
||||
Recruiter.name.label("recruiter_name"),
|
||||
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
|
||||
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
|
||||
func.coalesce(stats.c.screened, 0).label("screened"),
|
||||
func.coalesce(stats.c.assessment, 0).label("assessment"),
|
||||
func.coalesce(stats.c.interviewed, 0).label("interviewed"),
|
||||
func.coalesce(stats.c.offered, 0).label("offered"),
|
||||
func.coalesce(stats.c.on_hold, 0).label("on_hold"),
|
||||
func.coalesce(stats.c.rejected, 0).label("rejected"),
|
||||
func.coalesce(stats.c.approved, 0).label("approved"),
|
||||
func.coalesce(stats.c.hired, 0).label("hired"),
|
||||
)
|
||||
.select_from(cls)
|
||||
.outerjoin(stats, stats.c.job_post_id == cls.id)
|
||||
.outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id)
|
||||
.where(cls.is_deleted == False) # noqa: E712
|
||||
)
|
||||
if active_only:
|
||||
statement = statement.where(cls.is_active == True) # noqa: E712
|
||||
if job_uids:
|
||||
statement = statement.where(cls.id.in_(job_uids))
|
||||
if search:
|
||||
like = f"%{search.strip()}%"
|
||||
statement = statement.where(
|
||||
or_(cls.title.ilike(like), cls.location.ilike(like), cls.department.ilike(like))
|
||||
)
|
||||
|
||||
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())
|
||||
if job_post_id is None:
|
||||
if skip:
|
||||
statement = statement.offset(skip)
|
||||
if top is not None:
|
||||
statement = statement.limit(top)
|
||||
result = await session.execute(statement)
|
||||
return list(result.mappings().all()), int(total or 0)
|
||||
|
||||
@classmethod
|
||||
async def list_departments(cls, session: AsyncSession, *, active_only: bool = False):
|
||||
"""Distinct non-empty departments on non-deleted job posts.
|
||||
|
|
@ -195,6 +401,39 @@ class JobPosts(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def ids_for_manager(cls, session: AsyncSession, user_id):
|
||||
"""Job posts this user owns: assigned hiring_manager, or opened from
|
||||
a requisition they created. The manager Candidates list and form
|
||||
scope both follow this chain."""
|
||||
uid = cls._as_uuid(user_id)
|
||||
if uid is None:
|
||||
return []
|
||||
from candidate_forms.models import Requisition
|
||||
|
||||
assigned = await session.execute(
|
||||
select(cls.id).where(
|
||||
cls.hiring_manager_id == uid,
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
via_req = await session.execute(
|
||||
select(cls.id)
|
||||
.join(Requisition, cls.requisition_id == Requisition.id)
|
||||
.where(
|
||||
Requisition.created_by == uid,
|
||||
Requisition.is_deleted == False, # noqa: E712
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
seen: set[uuid.UUID] = set()
|
||||
out: list[uuid.UUID] = []
|
||||
for row_id in list(assigned.scalars().all()) + list(via_req.scalars().all()):
|
||||
if row_id not in seen:
|
||||
seen.add(row_id)
|
||||
out.append(row_id)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
|
||||
"""Open requisitions per hiring manager, keyed by users.id."""
|
||||
|
|
@ -390,6 +629,7 @@ class JobPosts(SQLModel, table=True):
|
|||
return None
|
||||
row.is_deleted = True
|
||||
row.is_active = False
|
||||
row.requisition_id = None
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
|
|
@ -543,4 +783,7 @@ class SocialPlatform(SQLModel, table=True):
|
|||
return {r.alias: r.buffer_service for r in rows}
|
||||
|
||||
|
||||
import users.models as _users_models
|
||||
# Requisition must be registered before Users relationships trigger mapper
|
||||
# configure — JobPosts.requisition_id FKs to app.requisitions.
|
||||
import candidate_forms.models as _requisition_models # noqa: E402, F401
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
|
|
@ -36,6 +36,7 @@ def serialize_job_post(row) -> dict:
|
|||
"created_by_name": row.user.name if getattr(row, "user", None) else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
|
|||
inbox, candidate and matching paths. department is the one shared field —
|
||||
talent-pool filters key off it on attached job_posts.
|
||||
"""
|
||||
req = getattr(row, "requisition", None)
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"title": row.title,
|
||||
|
|
@ -70,6 +72,9 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
|
|||
"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,
|
||||
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
||||
"requisition_title": req.position_title if req else None,
|
||||
"requisition_department": req.department if req else None,
|
||||
"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,
|
||||
|
|
@ -78,6 +83,30 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
|
|||
}
|
||||
|
||||
|
||||
def serialize_job_stats(row) -> dict:
|
||||
"""One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats."""
|
||||
recruiter_id=row.get("current_recruiter_id")
|
||||
return {
|
||||
"job_post_id": str(row["job_post_id"]),
|
||||
"title": row["title"],
|
||||
"department": row["department"] or None,
|
||||
"location": row["location"],
|
||||
"requisition_status": row["requisition_status"],
|
||||
"current_recruiter_id": str(recruiter_id) if recruiter_id else None,
|
||||
"recruiter_name": row.get("recruiter_name") or None,
|
||||
"total_applicants": int(row["total_applicants"] or 0),
|
||||
"shortlisting": int(row["shortlisting"] or 0),
|
||||
"screened": int(row["screened"] or 0),
|
||||
"assessment": int(row["assessment"] or 0),
|
||||
"interviewed": int(row["interviewed"] or 0),
|
||||
"offered": int(row["offered"] or 0),
|
||||
"on_hold": int(row["on_hold"] or 0),
|
||||
"rejected": int(row["rejected"] or 0),
|
||||
"approved": int(row["approved"] or 0),
|
||||
"hired": int(row["hired"] or 0),
|
||||
}
|
||||
|
||||
|
||||
def serialize_status_history(row, *, changed_by_name=None) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from uuid import UUID
|
|||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, model_validator
|
||||
from inbox.models import Inbox_Messages
|
||||
|
|
@ -26,7 +27,7 @@ from job.job_post.plugins import (
|
|||
render_job_post,
|
||||
resolve_channel,
|
||||
)
|
||||
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_status_history
|
||||
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history
|
||||
|
||||
load_dotenv()
|
||||
logger=logging.getLogger("job.job_post")
|
||||
|
|
@ -64,8 +65,9 @@ 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
|
||||
hiring_manager_id: UUID | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
requisition_id: UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_and_due_at(self):
|
||||
|
|
@ -146,10 +148,12 @@ class JobPost:
|
|||
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
|
||||
hm=None
|
||||
if payload.get("hiring_manager_id"):
|
||||
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(
|
||||
|
|
@ -157,9 +161,33 @@ class JobPost:
|
|||
)
|
||||
fields["current_recruiter_id"]=rec.id
|
||||
|
||||
row=await JobPosts.insert_job_post(self.session,fields)
|
||||
if payload.get("requisition_id"):
|
||||
from candidate_forms.models import Requisition
|
||||
req=await Requisition.get_form_by_id(
|
||||
self.session, record_id=str(payload["requisition_id"]),
|
||||
)
|
||||
if not req:
|
||||
raise HTTPException(status_code=404, detail="Requisition not found")
|
||||
held=await JobPosts.get_by_requisition_id(self.session, req.id)
|
||||
if held:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This requisition is already linked to a job post",
|
||||
)
|
||||
fields["requisition_id"]=req.id
|
||||
|
||||
try:
|
||||
row=await JobPosts.insert_job_post(self.session,fields)
|
||||
except IntegrityError as e:
|
||||
orig=str(getattr(e,"orig",e)).lower()
|
||||
if "requisition" in orig:
|
||||
raise HTTPException(
|
||||
status_code=409,detail="This requisition is already linked to a job post",
|
||||
) from e
|
||||
raise
|
||||
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 hm:
|
||||
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)
|
||||
|
||||
|
|
@ -206,6 +234,28 @@ class JobPost:
|
|||
)
|
||||
return [serialize_job_post(r) for r in rows],total
|
||||
|
||||
async def fetch_job_stats(self,job_post_id=None,search=None,ids=None,top=None,skip=0,active_only=False):
|
||||
uid=None
|
||||
if job_post_id not in (None,""):
|
||||
uid=JobPosts._as_uuid(job_post_id)
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=422,detail="job_post_id must be a UUID")
|
||||
rows,total=await JobPosts.fetch_job_stats(
|
||||
self.session,
|
||||
job_post_id=uid,
|
||||
search=search,
|
||||
ids=ids,
|
||||
top=top,
|
||||
skip=skip,
|
||||
active_only=active_only,
|
||||
)
|
||||
data=[serialize_job_stats(r) for r in rows]
|
||||
if uid is not None:
|
||||
if not data:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
return data[0],1
|
||||
return data,total
|
||||
|
||||
async def fetch_departments(self,active_only=False):
|
||||
return await JobPosts.list_departments(self.session,active_only=active_only)
|
||||
|
||||
|
|
@ -288,11 +338,29 @@ class JobPost:
|
|||
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 raw is None or raw=="":
|
||||
fields["hiring_manager_id"]=None
|
||||
hm_changed=existing.hiring_manager_id is not None
|
||||
else:
|
||||
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 "requisition_id" in payload:
|
||||
raw=payload.get("requisition_id")
|
||||
if raw is None or raw=="":
|
||||
fields["requisition_id"]=None
|
||||
else:
|
||||
from candidate_forms.models import Requisition
|
||||
req=await Requisition.get_form_by_id(self.session,record_id=str(raw))
|
||||
if not req:
|
||||
raise HTTPException(status_code=404,detail="Requisition not found")
|
||||
held=await JobPosts.get_by_requisition_id(self.session, req.id)
|
||||
if held and str(held.id)!=str(existing.id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This requisition is already linked to a job post",
|
||||
)
|
||||
fields["requisition_id"]=req.id
|
||||
if "current_recruiter_id" in payload:
|
||||
raw=payload.get("current_recruiter_id")
|
||||
if raw is None or raw=="":
|
||||
|
|
@ -305,7 +373,15 @@ class JobPost:
|
|||
|
||||
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)
|
||||
try:
|
||||
row=await JobPosts.update_job_post(self.session,job_post_id,fields)
|
||||
except IntegrityError as e:
|
||||
orig=str(getattr(e,"orig",e)).lower()
|
||||
if "requisition" in orig:
|
||||
raise HTTPException(
|
||||
status_code=409,detail="This requisition is already linked to a job post",
|
||||
) from e
|
||||
raise
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
if hm_changed:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from job.candidate.models import Notes
|
||||
from job.candidate.views import assert_manager_candidate_access
|
||||
from job.history.enums import HistoryEvent
|
||||
from job.history.views import HistoryRecorder
|
||||
from job.notes.serializers import serialize_note
|
||||
|
|
@ -14,17 +15,21 @@ class Note:
|
|||
async def _load(self,record_id):
|
||||
return await Notes.get_note_by_id(self.session,record_id)
|
||||
|
||||
async def get_note(self,note_id=None,user_id=None):
|
||||
async def get_note(self,note_id=None,user_id=None,current_user=None):
|
||||
if note_id:
|
||||
row=await self._load(note_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Note not found")
|
||||
await assert_manager_candidate_access(
|
||||
self.session,current_user,user_id=row.user_id,
|
||||
)
|
||||
return serialize_note(row)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400,detail="note_id or user_id is required")
|
||||
uid=Notes._as_uuid(user_id)
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=400,detail="Invalid user_id")
|
||||
await assert_manager_candidate_access(self.session,current_user,user_id=uid)
|
||||
rows=await Notes.get_notes_by_user(self.session,uid)
|
||||
return [serialize_note(r) for r in rows]
|
||||
|
||||
|
|
@ -36,6 +41,9 @@ class Note:
|
|||
}
|
||||
if not fields["user_id"]:
|
||||
raise HTTPException(status_code=400,detail="user_id is required")
|
||||
await assert_manager_candidate_access(
|
||||
self.session,current_user,user_id=fields["user_id"],
|
||||
)
|
||||
row=await Notes.insert_note(self.session,fields)
|
||||
await HistoryRecorder(self.session).record(
|
||||
HistoryEvent.NOTE_CREATED.value,
|
||||
|
|
@ -53,6 +61,9 @@ class Note:
|
|||
before=await self._load(note_id)
|
||||
if not before:
|
||||
raise HTTPException(status_code=404,detail="Note not found")
|
||||
await assert_manager_candidate_access(
|
||||
self.session,current_user,user_id=before.user_id,
|
||||
)
|
||||
old_note=before.note or ""
|
||||
row=await Notes.update_note(self.session,note_id,fields)
|
||||
if not row:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
-- 019_requisitions_rbac.sql
|
||||
-- Manual one-shot: the `requisitions` permission module (8 tags), a
|
||||
-- `requisitions_management` bundle holding them, and the bundle attached to
|
||||
-- the staff roles that fill Employee Requisition forms (Annexure A). Mirrors
|
||||
-- 007's idempotent pattern; applied automatically at startup by
|
||||
-- alembic_setup.run_manual_sql() and recorded in manual_migrations.
|
||||
--
|
||||
-- The all_access bundle is a fixed id list seeded before this module existed,
|
||||
-- so system_administrator gets requisitions access through THIS bundle, not
|
||||
-- that one. Users must log in again after this applies — permissions are
|
||||
-- resolved from the DB per request, but the frontend caches the list from
|
||||
-- /users/me.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. The 8 requisitions.* permission tags
|
||||
-- =============================================================================
|
||||
INSERT INTO app.permission_tags
|
||||
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
|
||||
VALUES
|
||||
('requisitions.view', 'requisitions', 'view', NULL, NOW(), NOW(), true, false),
|
||||
('requisitions.create', 'requisitions', 'create', NULL, NOW(), NOW(), true, false),
|
||||
('requisitions.edit', 'requisitions', 'edit', NULL, NOW(), NOW(), true, false),
|
||||
('requisitions.delete', 'requisitions', 'delete', NULL, NOW(), NOW(), true, false),
|
||||
('requisitions.approve', 'requisitions', 'approve', NULL, NOW(), NOW(), true, false),
|
||||
('requisitions.export', 'requisitions', 'export', NULL, NOW(), NOW(), true, false),
|
||||
('requisitions.manage', 'requisitions', 'manage', NULL, NOW(), NOW(), true, false),
|
||||
('requisitions.configure', 'requisitions', 'configure', NULL, NOW(), NOW(), true, false)
|
||||
ON CONFLICT (tag_name) DO NOTHING;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Bundle holding all eight requisitions tags
|
||||
-- =============================================================================
|
||||
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||
SELECT
|
||||
'requisitions_management',
|
||||
'Employee requisition forms: view, create, edit and manage requisitions',
|
||||
(
|
||||
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||
FROM app.permission_tags
|
||||
WHERE is_deleted = false
|
||||
AND module = 'requisitions'
|
||||
),
|
||||
true,
|
||||
NOW(),
|
||||
NOW(),
|
||||
true,
|
||||
false
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM app.permissions WHERE name = 'requisitions_management'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Attach the bundle to the staff roles (idempotent; same role list as 007)
|
||||
-- =============================================================================
|
||||
UPDATE app.roles r
|
||||
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
||||
updated_at = NOW()
|
||||
FROM app.permissions p
|
||||
WHERE p.name = 'requisitions_management'
|
||||
AND r.role_name IN (
|
||||
'system_administrator',
|
||||
'hr_administrator',
|
||||
'recruiter',
|
||||
'hiring_manager',
|
||||
'department_head',
|
||||
'ceo'
|
||||
)
|
||||
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
-- 020_requisitions.sql
|
||||
-- Employee Requisition (Annexure A) as its own table, separate from
|
||||
-- candidate_forms (Interview Analysis / Cultural Fit). Nested request objects
|
||||
-- flatten onto columns: position.title → position_title, replacement_for.title
|
||||
-- → recruitment_title, refferal_by → employee_name / employee_department.
|
||||
-- employment type is a CHECK over EmploymentType values, not a native PG enum
|
||||
-- (point releases of the Python enum should not require a type ALTER).
|
||||
--
|
||||
-- "date" and "type" are quoted: both are PostgreSQL keywords.
|
||||
--
|
||||
-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql()
|
||||
-- and recorded in manual_migrations. Matches Requisition in
|
||||
-- backend/candidate_forms/models.py (needed here because prod boots with
|
||||
-- DB_AUTOGENERATE=false and never autogenerates new tables).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.requisitions (
|
||||
id uuid PRIMARY KEY,
|
||||
department varchar,
|
||||
position_title varchar,
|
||||
"date" date,
|
||||
date_needed date,
|
||||
"type" varchar
|
||||
CHECK ("type" IS NULL OR "type" IN (
|
||||
'permanent', 'contract', 'temporary', 'internee'
|
||||
)),
|
||||
job_description text,
|
||||
|
||||
employee_name varchar,
|
||||
employee_department varchar,
|
||||
|
||||
to_replace varchar,
|
||||
grade varchar,
|
||||
recruitment_title varchar,
|
||||
date_separated date,
|
||||
justification text,
|
||||
budget varchar,
|
||||
recommended_grade varchar,
|
||||
|
||||
initiated_by varchar,
|
||||
initiated_date date,
|
||||
recommended_by varchar,
|
||||
recommended_date date,
|
||||
approved_by_hr boolean,
|
||||
approved_by_date_hr date,
|
||||
approved_by_vp boolean,
|
||||
approved_by_date_vp date,
|
||||
approved_by_svp boolean,
|
||||
approved_by_date_svp date,
|
||||
|
||||
created_by uuid REFERENCES app.users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
is_deleted boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_requisitions_created_by
|
||||
ON app.requisitions (created_by);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_requisitions_is_deleted
|
||||
ON app.requisitions (is_deleted);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
-- 021_job_post_requisition.sql
|
||||
-- Optional link from a job post to the Annexure A employee requisition it
|
||||
-- was opened from (app.requisitions). Distinct from requisition_status, which
|
||||
-- is the hiring lifecycle on job_posts. 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 requisition_id UUID REFERENCES app.requisitions(id) ON DELETE SET NULL;
|
||||
|
||||
-- Unique so one requisition maps to at most one job post. Postgres unique
|
||||
-- indexes allow multiple NULLs, so unlinked job posts stay valid.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_job_posts_requisition_id
|
||||
ON app.job_posts (requisition_id);
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
-- 022_job_post_requisition_unique.sql
|
||||
-- 021 originally created a non-unique ix_job_posts_requisition_id. Replace it
|
||||
-- with a unique index so the optional job_posts.requisition_id link is 1:1.
|
||||
-- Postgres unique indexes allow multiple NULLs. Applied at startup by
|
||||
-- alembic_setup.run_manual_sql(). Needed because prod boots with
|
||||
-- DB_AUTOGENERATE=false.
|
||||
|
||||
DROP INDEX IF EXISTS app.ix_job_posts_requisition_id;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_job_posts_requisition_id
|
||||
ON app.job_posts (requisition_id);
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
-- 023_requisition_employmenttype.sql
|
||||
-- Native PG enum for Annexure A employment type. 020 stored this as a quoted
|
||||
-- varchar "type" with a CHECK; SQLAlchemy maps EmploymentType to
|
||||
-- app.employmenttype, which 020 never created. Rename "type" (a PG keyword)
|
||||
-- to employment_type and convert the column. Applied at startup by
|
||||
-- alembic_setup.run_manual_sql(). Needed because prod boots with
|
||||
-- DB_AUTOGENERATE=false.
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE app.employmenttype AS ENUM (
|
||||
'permanent', 'contract', 'temporary', 'internee'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT con.conname
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
|
||||
WHERE nsp.nspname = 'app'
|
||||
AND rel.relname = 'requisitions'
|
||||
AND con.contype = 'c'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE app.requisitions DROP CONSTRAINT IF EXISTS %I', r.conname);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'requisitions'
|
||||
AND column_name = 'type'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'requisitions'
|
||||
AND column_name = 'employment_type'
|
||||
) THEN
|
||||
ALTER TABLE app.requisitions RENAME COLUMN "type" TO employment_type;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'requisitions'
|
||||
AND column_name = 'employment_type'
|
||||
AND udt_name <> 'employmenttype'
|
||||
) THEN
|
||||
ALTER TABLE app.requisitions
|
||||
ALTER COLUMN employment_type TYPE app.employmenttype
|
||||
USING CASE
|
||||
WHEN employment_type IS NULL OR btrim(employment_type) = '' THEN NULL
|
||||
ELSE lower(employment_type)::app.employmenttype
|
||||
END;
|
||||
END IF;
|
||||
END $$;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
-- 024_manager_candidates_rbac.sql
|
||||
-- Manual one-shot: hiring_manager can list candidates on their requisition
|
||||
-- jobs and write notes on those profiles. Form fill already comes from
|
||||
-- hiring_forms (interviews.create/edit) + analytics_dashboard (interviews.view).
|
||||
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||
--
|
||||
-- Users must log in again after this applies — the frontend caches /users/me.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Bundle: candidates.view / create / edit (list + notes)
|
||||
-- =============================================================================
|
||||
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||
SELECT
|
||||
'manager_candidates',
|
||||
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
|
||||
(
|
||||
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||
FROM app.permission_tags
|
||||
WHERE is_deleted = false
|
||||
AND tag_name IN ('candidates.view', 'candidates.create', 'candidates.edit')
|
||||
),
|
||||
true,
|
||||
NOW(),
|
||||
NOW(),
|
||||
true,
|
||||
false
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM app.permissions WHERE name = 'manager_candidates'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Attach the bundle to hiring_manager only
|
||||
-- =============================================================================
|
||||
UPDATE app.roles r
|
||||
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
||||
updated_at = NOW()
|
||||
FROM app.permissions p
|
||||
WHERE p.name = 'manager_candidates'
|
||||
AND r.role_name = 'hiring_manager'
|
||||
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
-- 025_manager_role_candidates_rbac.sql
|
||||
-- The Access Control role named Manager (Ahmed Baig) is not the seeded
|
||||
-- hiring_manager role. 024 only attached manager_candidates to hiring_manager,
|
||||
-- so Manager had 0 candidates.* tags and the Candidates nav item never appeared
|
||||
-- (routes.js permission is candidates.view). Same hole broke Calendar:
|
||||
-- GET /interview/fetch is gated on candidates.view, not interviews.view.
|
||||
-- Applied at startup by alembic_setup.run_manual_sql(). Log in again after.
|
||||
|
||||
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||
SELECT
|
||||
'manager_candidates',
|
||||
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
|
||||
(
|
||||
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||
FROM app.permission_tags
|
||||
WHERE is_deleted = false
|
||||
AND tag_name IN ('candidates.view', 'candidates.create', 'candidates.edit')
|
||||
),
|
||||
true,
|
||||
NOW(),
|
||||
NOW(),
|
||||
true,
|
||||
false
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM app.permissions WHERE name = 'manager_candidates'
|
||||
);
|
||||
|
||||
UPDATE app.roles r
|
||||
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
||||
updated_at = NOW()
|
||||
FROM app.permissions p
|
||||
WHERE p.name = 'manager_candidates'
|
||||
AND lower(r.role_name) IN ('hiring_manager', 'manager')
|
||||
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||
|
|
@ -177,7 +177,7 @@ class Users(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
async def get_user_by_email(cls, session: AsyncSession, email: str):
|
||||
statement = select(cls).options(selectinload(cls.role)).where(cls.email == email)
|
||||
statement = select(cls).options(selectinload(cls.role)).where(cls.email == email,cls.role_id != 8)
|
||||
result = await session.execute(statement)
|
||||
return result.scalars().first()
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db_setup import get_session
|
||||
from role.models import Roles
|
||||
from role.models import EnumRoles, Roles
|
||||
from users.models import Users
|
||||
from users.plugins import decode_token
|
||||
from users.serializers import serialize_user
|
||||
|
|
@ -40,7 +40,7 @@ class PermissionModule(str, Enum):
|
|||
RBAC_USERS = "rbac_users"
|
||||
TASKS = "tasks"
|
||||
TALENT = "talent"
|
||||
|
||||
REQUISITIONS = "requisitions"
|
||||
|
||||
class PermissionAction(str, Enum):
|
||||
VIEW = "view"
|
||||
|
|
@ -54,6 +54,15 @@ class PermissionAction(str, Enum):
|
|||
|
||||
|
||||
class PermissionTag(str, Enum):
|
||||
REQUISITIONS_VIEW = "requisitions.view"
|
||||
REQUISITIONS_CREATE = "requisitions.create"
|
||||
REQUISITIONS_EDIT = "requisitions.edit"
|
||||
REQUISITIONS_DELETE = "requisitions.delete"
|
||||
REQUISITIONS_APPROVE = "requisitions.approve"
|
||||
REQUISITIONS_EXPORT = "requisitions.export"
|
||||
REQUISITIONS_MANAGE = "requisitions.manage"
|
||||
REQUISITIONS_CONFIGURE = "requisitions.configure"
|
||||
|
||||
DASHBOARD_VIEW = "dashboard.view"
|
||||
DASHBOARD_CREATE = "dashboard.create"
|
||||
DASHBOARD_EDIT = "dashboard.edit"
|
||||
|
|
@ -194,6 +203,37 @@ def _assert_vocabulary_complete() -> None:
|
|||
_assert_vocabulary_complete()
|
||||
|
||||
|
||||
def is_hiring_manager(current_user: dict | None) -> bool:
|
||||
"""Hiring-manager portal: seeded hiring_manager, or a custom Manager role.
|
||||
|
||||
Ahmed Baig's Access Control role is named Manager (not hiring_manager).
|
||||
Matching is case-insensitive so the sidebar and API scope agree.
|
||||
"""
|
||||
name = ((current_user or {}).get("role_name") or "").strip().lower()
|
||||
return name in {EnumRoles.HIRING_MANAGER.value, "manager"}
|
||||
|
||||
|
||||
_ADMIN_ROLES = {
|
||||
EnumRoles.SYSTEM_ADMINISTRATOR.value,
|
||||
EnumRoles.HR_ADMINISTRATOR.value,
|
||||
"admin",
|
||||
}
|
||||
|
||||
|
||||
def is_admin(current_user: dict | None) -> bool:
|
||||
"""Org-wide staff: seeded admin roles, a custom Admin role, or requisitions.manage.
|
||||
|
||||
Managers keep a created_by-scoped requisition list. Admins see every
|
||||
non-deleted requisition, linked to a job post or not.
|
||||
"""
|
||||
user = current_user or {}
|
||||
name = (user.get("role_name") or "").strip().lower()
|
||||
if name in _ADMIN_ROLES:
|
||||
return True
|
||||
granted = user.get("permissions") or []
|
||||
return PermissionTag.REQUISITIONS_MANAGE.value in granted
|
||||
|
||||
|
||||
def has_permission(
|
||||
granted: set[str] | list[str] | tuple[str, ...],
|
||||
*required: PermissionTag,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ server {
|
|||
}
|
||||
|
||||
# API-only prefixes (no SPA page at the bare path).
|
||||
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3)(/|$) {
|
||||
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms)(/|$) {
|
||||
proxy_pass http://backend-api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
|
@ -57,18 +57,39 @@ server {
|
|||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Hashed filenames, so they can be cached hard.
|
||||
# Hashed filenames, so they can be cached hard. A miss after a rebuild is a
|
||||
# stale tab (old import() hash) — 404 + immutable would pin that miss for a
|
||||
# year, so JS falls through to a one-shot reload of index.html.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
||||
location ~ \.js$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
try_files $uri @stale_js;
|
||||
}
|
||||
}
|
||||
|
||||
location @stale_js {
|
||||
default_type application/javascript;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
return 200 "try{if(!sessionStorage.getItem('tf-chunk-reload')){sessionStorage.setItem('tf-chunk-reload','1');location.reload();}else{sessionStorage.removeItem('tf-chunk-reload');}}catch(e){location.reload();}";
|
||||
}
|
||||
|
||||
# index.html must never be cached, or a redeploy keeps serving the old asset hashes.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store";
|
||||
etag off;
|
||||
if_modified_since off;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import Signup from './pages/Signup'
|
|||
import ForgotPassword from './pages/ForgotPassword'
|
||||
import ConfirmEmail from './pages/ConfirmEmail'
|
||||
|
||||
// Route-level code splitting: putting 23 screens in one bundle would make the
|
||||
// Route-level code splitting: putting 24 screens in one bundle would make the
|
||||
// first paint pay for every screen a user never opens.
|
||||
const SCREENS = {
|
||||
dashboard: lazy(() => import('./screens/Dashboard')),
|
||||
|
|
@ -22,6 +22,7 @@ const SCREENS = {
|
|||
candidates: lazy(() => import('./screens/Candidates')),
|
||||
talentpool: lazy(() => import('./screens/TalentPool')),
|
||||
pipeline: lazy(() => import('./screens/Pipeline')),
|
||||
progress: lazy(() => import('./screens/Progress')),
|
||||
import: lazy(() => import('./screens/CvImport')),
|
||||
jobboard: lazy(() => import('./screens/JobBoard')),
|
||||
recruiterhub: lazy(() => import('./screens/RecruiterHub')),
|
||||
|
|
@ -29,6 +30,7 @@ const SCREENS = {
|
|||
tasks: lazy(() => import('./screens/Tasks')),
|
||||
aiassistant: lazy(() => import('./screens/AiAssistant')),
|
||||
interviews: lazy(() => import('./screens/Interviews')),
|
||||
requisitions: lazy(() => import('./screens/Requisitions')),
|
||||
assessments: lazy(() => import('./screens/Assessments')),
|
||||
offers: lazy(() => import('./screens/Offers')),
|
||||
managers: lazy(() => import('./screens/Managers')),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import Talent from '../screens/Talent'
|
|||
import Tasks from '../screens/Tasks'
|
||||
import AiAssistant from '../screens/AiAssistant'
|
||||
import Interviews from '../screens/Interviews'
|
||||
import Requisitions from '../screens/Requisitions'
|
||||
import Assessments from '../screens/Assessments'
|
||||
import Offers from '../screens/Offers'
|
||||
import Managers from '../screens/Managers'
|
||||
|
|
@ -53,7 +54,7 @@ const SCREENS = {
|
|||
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
||||
talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard,
|
||||
recruiterhub: RecruiterHub, talent: Talent, tasks: Tasks, aiassistant: AiAssistant,
|
||||
interviews: Interviews, assessments: Assessments, offers: Offers,
|
||||
interviews: Interviews, requisitions: Requisitions, assessments: Assessments, offers: Offers,
|
||||
managers: Managers, calendar: Calendar, reports: Reports, analytics: Analytics,
|
||||
aistudio: AiStudio, notifications: Notifications, rbac: Rbac,
|
||||
settings: Settings, help: Help,
|
||||
|
|
|
|||
|
|
@ -259,6 +259,15 @@ export function getByUserId(userId) {
|
|||
return request('/candidate/fetch', { params: { user_id: userId } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidates allocated to jobs this hiring manager owns — requisitions they
|
||||
* created (or are assigned on) → linked job posts → applications.
|
||||
* Needs candidates.view. Server-scoped; recruiters should not use this.
|
||||
*/
|
||||
export function listForManager({ limit = 50, offset = 0 } = {}) {
|
||||
return request('/candidate/manager/fetch', { params: { limit, offset } })
|
||||
}
|
||||
|
||||
/** `data` is a list on the list path and a bare object on the by-id path. */
|
||||
export function toRows(res) {
|
||||
if (Array.isArray(res?.data)) return res.data
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
/* ============================================================
|
||||
jobStats.js — per-job pipeline stage counts (GET /job/stats/fetch).
|
||||
|
||||
Live aggregation over inbox + manual upload + unpromoted sheet rows,
|
||||
deduped by email. Needs jobs.view or pipeline.view.
|
||||
============================================================ */
|
||||
|
||||
import { request } from '../lib/apiClient'
|
||||
import { REQUISITION_STATUSES } from './jobs'
|
||||
|
||||
const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label]))
|
||||
|
||||
/**
|
||||
* List or single-job stage counts.
|
||||
* Omit jobPostId for a paged list; pass jobPostId for one object.
|
||||
*/
|
||||
export function list({ jobPostId, search, ids, top, skip, activeOnly } = {}) {
|
||||
return request('/job/stats/fetch', {
|
||||
params: {
|
||||
job_post_id: jobPostId,
|
||||
search,
|
||||
ids: Array.isArray(ids) ? ids.join(',') : ids,
|
||||
top,
|
||||
skip,
|
||||
active_only: activeOnly,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** API row -> what Progress cards and the table render. */
|
||||
export function toJobStatsView(row) {
|
||||
return {
|
||||
id: row.job_post_id,
|
||||
title: row.title || 'Untitled role',
|
||||
department: row.department || null,
|
||||
location: row.location || null,
|
||||
status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status ?? '—',
|
||||
requisitionStatus: row.requisition_status,
|
||||
recruiterId: row.current_recruiter_id || null,
|
||||
recruiterName: row.recruiter_name || null,
|
||||
total: Number(row.total_applicants) || 0,
|
||||
shortlist: Number(row.shortlisting) || 0,
|
||||
screened: Number(row.screened) || 0,
|
||||
assessment: Number(row.assessment) || 0,
|
||||
interviewed: Number(row.interviewed) || 0,
|
||||
offered: Number(row.offered) || 0,
|
||||
onHold: Number(row.on_hold) || 0,
|
||||
rejected: Number(row.rejected) || 0,
|
||||
approved: Number(row.approved) || 0,
|
||||
hired: Number(row.hired) || 0,
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +91,12 @@ export function toJobView(row) {
|
|||
skills: row.requirements ?? [],
|
||||
optionalSkills: row.optional_skills ?? [],
|
||||
description: row.description,
|
||||
requisitionId: row.requisition_id || null,
|
||||
requisitionTitle: row.requisition_title || '',
|
||||
requisitionDepartment: row.requisition_department || '',
|
||||
requisitionLabel: row.requisition_id
|
||||
? `${(row.requisition_title || 'Untitled').trim() || 'Untitled'} - ${(row.requisition_department || '—').trim() || '—'}`
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/* ============================================================
|
||||
requisitions.js — backend/candidate_forms/app.py requisition routes.
|
||||
|
||||
Standalone Annexure A table (app.requisitions), not candidate_forms.
|
||||
created_by comes from the JWT on the server — do not send it in the body.
|
||||
============================================================ */
|
||||
|
||||
export const EMPLOYMENT_TYPES = [
|
||||
{ value: 'permanent', label: 'Permanent' },
|
||||
{ value: 'contract', label: 'Contract' },
|
||||
{ value: 'temporary', label: 'Temporary' },
|
||||
{ value: 'internee', label: 'Internee' },
|
||||
]
|
||||
|
||||
export const EMPLOYMENT_TYPE_LABEL = Object.fromEntries(
|
||||
EMPLOYMENT_TYPES.map((t) => [t.value, t.label]),
|
||||
)
|
||||
|
||||
export function list() {
|
||||
return request('/forms/requisition/fetch')
|
||||
}
|
||||
|
||||
export function getById(formId) {
|
||||
return request('/forms/requisition/fetch', { params: { form_id: formId } })
|
||||
}
|
||||
|
||||
/** Searchable picker — GET /forms/requisition/search. `q` matches title or department.
|
||||
* `jobPostId` keeps the job's current requisition in the list while editing. */
|
||||
export function search({ q, top, jobPostId } = {}) {
|
||||
return request('/forms/requisition/search', {
|
||||
params: {
|
||||
q: q || undefined,
|
||||
top,
|
||||
job_post_id: jobPostId || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function create(body) {
|
||||
return request('/forms/requisition/create', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function update(formId, body) {
|
||||
return request('/forms/requisition/update', {
|
||||
method: 'PATCH',
|
||||
params: { form_id: formId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function toRows(res) {
|
||||
const data = res?.data
|
||||
if (Array.isArray(data)) return data
|
||||
if (data) return [data]
|
||||
return []
|
||||
}
|
||||
|
||||
export function approvalStatus(row) {
|
||||
if (row?.approved_by_svp) return { key: 'approved', label: 'Approved' }
|
||||
if (row?.approved_by_hr || row?.approved_by_vp) return { key: 'review', label: 'In review' }
|
||||
return { key: 'open', label: 'Open' }
|
||||
}
|
||||
|
|
@ -1,15 +1,20 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { NAV_GROUPS, ROUTES } from './routes'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { HIRING_MANAGER_NAV, isHiringManager } from '../auth/permissions'
|
||||
import Icon from '../ui/icons'
|
||||
import { BrandGlyph } from '../components/BrandMark'
|
||||
|
||||
export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badges }) {
|
||||
const { can } = useAuth()
|
||||
const { can, user } = useAuth()
|
||||
|
||||
// A group heading renders only if something under it survived the permission
|
||||
// filter — otherwise a low-privilege user sees orphaned section labels.
|
||||
const visible = ROUTES.filter((r) => can(r.permission))
|
||||
const visible = ROUTES.filter((r) => {
|
||||
if (!can(r.permission)) return false
|
||||
if (isHiringManager(user) && !HIRING_MANAGER_NAV.has(r.path)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* ============================================================
|
||||
routes.js — the 23-route information architecture.
|
||||
routes.js — the 24-route information architecture.
|
||||
|
||||
This is the one artefact ADR 0013 says to preserve outright: the module
|
||||
breakdown, nav grouping and screen inventory are a validated UX artefact
|
||||
|
|
@ -23,6 +23,7 @@ export const ROUTES = [
|
|||
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
|
||||
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },
|
||||
{ path: 'pipeline', title: 'Pipeline', icon: 'pipeline', group: 'Workspace', permission: 'pipeline.view' },
|
||||
{ path: 'progress', title: 'Progress', icon: 'trending-up', group: 'Workspace', permission: 'jobs.view' },
|
||||
|
||||
// --- Recruiting ---
|
||||
{ path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' },
|
||||
|
|
@ -34,6 +35,7 @@ export const ROUTES = [
|
|||
|
||||
// --- Hiring ---
|
||||
{ path: 'interviews', title: 'Interviews', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
|
||||
{ path: 'requisitions', title: 'Requisitions', icon: 'file', group: 'Hiring', permission: 'requisitions.view' },
|
||||
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
|
||||
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' },
|
||||
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' },
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
export const MODULES = [
|
||||
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
||||
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks',
|
||||
'talent',
|
||||
'talent', 'requisitions',
|
||||
]
|
||||
|
||||
export const ACTIONS = [
|
||||
|
|
@ -34,3 +34,17 @@ export function makeCan(permissions) {
|
|||
const set = new Set(permissions ?? [])
|
||||
return (tag) => !tag || set.has(tag)
|
||||
}
|
||||
|
||||
export const HIRING_MANAGER_ROLE = 'hiring_manager'
|
||||
|
||||
/** Sidebar paths a manager-type role may see. Talent Pool / Matching / Import
|
||||
also sit on candidates.view/create, so they are excluded here. */
|
||||
export const HIRING_MANAGER_NAV = new Set([
|
||||
'candidates', 'requisitions', 'interviews', 'calendar',
|
||||
'help', 'aiassistant', 'aistudio', 'notifications',
|
||||
])
|
||||
|
||||
export function isHiringManager(user) {
|
||||
const name = (user?.role_name || '').trim().toLowerCase()
|
||||
return name === HIRING_MANAGER_ROLE || name === 'manager'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export const qk = {
|
|||
list: (p = {}) => ['jobs', 'list', p],
|
||||
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
||||
statusHistory: (id) => ['jobs', 'status-history', id],
|
||||
stats: (p = {}) => ['jobs', 'stats', p],
|
||||
},
|
||||
talent: {
|
||||
all: () => ['talent'],
|
||||
|
|
@ -93,6 +94,7 @@ export const qk = {
|
|||
candidates: {
|
||||
all: () => ['candidates'],
|
||||
list: (p = {}) => ['candidates', 'list', p],
|
||||
managerList: (p = {}) => ['candidates', 'manager', p],
|
||||
count: (p = {}) => ['candidates', 'count', p],
|
||||
detail: (id) => ['candidates', 'detail', id],
|
||||
history: (id, p = {}) => ['candidates', 'history', id, p],
|
||||
|
|
@ -125,6 +127,12 @@ export const qk = {
|
|||
list: (p = {}) => ['forms', 'list', p],
|
||||
definitions: () => ['forms', 'definitions'],
|
||||
},
|
||||
requisitions: {
|
||||
all: () => ['requisitions'],
|
||||
list: () => ['requisitions', 'list'],
|
||||
detail: (id) => ['requisitions', 'detail', id],
|
||||
search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
|
||||
},
|
||||
interviews: {
|
||||
all: () => ['interviews'],
|
||||
range: (p = {}) => ['interviews', 'range', p],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Alert from '../components/Alert'
|
|||
import Spinner from '../components/Spinner'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
|
||||
export default function Login() {
|
||||
const form = useFormState({ email: '', password: '' })
|
||||
|
|
@ -28,9 +29,12 @@ export default function Login() {
|
|||
form.setAlert(null)
|
||||
form.setBusy(true)
|
||||
try {
|
||||
await signIn(form.values.email.trim(), form.values.password)
|
||||
// In-SPA now: the old full page load out to /index.html#dashboard is gone.
|
||||
navigate(from, { replace: true })
|
||||
const res = await signIn(form.values.email.trim(), form.values.password)
|
||||
const role = res?.data?.role_name
|
||||
const dest = (from === '/dashboard' || from === '/') && isHiringManager({ role_name: role })
|
||||
? '/candidates'
|
||||
: from
|
||||
navigate(dest, { replace: true })
|
||||
} catch (err) {
|
||||
form.setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not sign in.') })
|
||||
form.setBusy(false)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* The Forms tab of the candidate profile modal — the digitized paper annexures:
|
||||
Employee Requisition (Annexure A), Interview Analysis + Cultural Fit (the two
|
||||
halves of Annexure E), and the Offer (Annexure J fields on the offers table).
|
||||
/* The Forms tab of the candidate profile modal — Interview Analysis + Cultural
|
||||
Fit (the two halves of Annexure E), and the Offer (Annexure J fields on the
|
||||
offers table). Employee Requisition (Annexure A) lives on the Requisitions
|
||||
screen, not on a candidate.
|
||||
|
||||
Field and criterion labels are rendered from GET /forms/definitions — the
|
||||
backend is the single authority for the paper forms' exact wording. The
|
||||
|
|
@ -12,9 +13,8 @@
|
|||
friendly version. Forms attach to an application — the inbox row for email
|
||||
applicants, the manual_upload_candidate row for hand-added candidates.
|
||||
|
||||
Layout system: .hf-* classes in styles.css. Rated criteria render as the
|
||||
paper's own table (scale header, radio-dot cells, the SECTION AVERAGE foot);
|
||||
the score summary is a stat-tile row with the combined overall as the hero. */
|
||||
Layout system: .hf-* classes in styles.css. Rated criteria render as a
|
||||
25/50/75/100% grid; section and combined totals are percentages. */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
|
@ -22,12 +22,29 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as formsApi from '../api/forms'
|
||||
import * as offersApi from '../api/offers'
|
||||
import { STAGE_FROM_STATUS } from '../api/pipeline'
|
||||
|
||||
const RATING_POINTS = [25, 50, 75, 100]
|
||||
|
||||
/** Criterion ticks and averages are 0–100. Legacy 1–4 values convert once. */
|
||||
function toPercent(score) {
|
||||
if (score == null || score === '') return null
|
||||
const n = Number(score)
|
||||
if (!Number.isFinite(n)) return null
|
||||
if (n > 0 && n <= 4) return n * 25
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function formatPercent(score) {
|
||||
const pct = toPercent(score)
|
||||
return pct == null ? null : `${pct}%`
|
||||
}
|
||||
|
||||
const WORK_LOCATIONS = ['Maymar Office', 'Head Office']
|
||||
const WORK_TIMINGS = ['Morning', 'Afternoon', 'Evening', 'Night']
|
||||
|
||||
|
|
@ -60,9 +77,10 @@ function useFormsWrite({ userId, mutationFn, success, onDone }) {
|
|||
}
|
||||
|
||||
export default function CandidateFormsTab({ userId, live }) {
|
||||
const { can } = useAuth()
|
||||
const { can, user } = useAuth()
|
||||
const isManager = isHiringManager(user)
|
||||
// Open on the process's first step; the switcher order IS the paper sequence.
|
||||
const [seg, setSeg] = useState('requisition')
|
||||
const [seg, setSeg] = useState('interview_analysis')
|
||||
|
||||
// Forms attach to an application: an inbox row for email applicants, or the
|
||||
// manual_upload_candidate row for hand-added / sourced candidates. Exactly
|
||||
|
|
@ -81,7 +99,7 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
queryKey: qk.forms.definitions(),
|
||||
queryFn: formsApi.definitions,
|
||||
enabled: hasApplication && unlocked,
|
||||
staleTime: Infinity,
|
||||
staleTime: 0,
|
||||
})
|
||||
const formsQuery = useQuery({
|
||||
queryKey: qk.forms.list(listParams),
|
||||
|
|
@ -92,7 +110,7 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
const offersQuery = useQuery({
|
||||
queryKey: qk.offers.list({ inboxId }),
|
||||
queryFn: () => offersApi.list({ inboxId }),
|
||||
enabled: Boolean(inboxId) && unlocked,
|
||||
enabled: Boolean(inboxId) && unlocked && !isManager,
|
||||
})
|
||||
|
||||
if (!hasApplication) {
|
||||
|
|
@ -107,7 +125,7 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
<EmptyState icon="lock" title="Forms unlock at the Interview stage">
|
||||
This candidate is at {STAGE_FROM_STATUS[stage] ?? titleCase(stage)} with no interview
|
||||
on record. Schedule an interview on the Interview tab, or move them along the
|
||||
pipeline, to fill the requisition, evaluation and offer forms.
|
||||
pipeline, to fill the evaluation and offer forms.
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
|
|
@ -124,24 +142,32 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
|
||||
const defs = defsQuery.data?.data
|
||||
const rows = formsQuery.data?.data ?? []
|
||||
if (!defs?.forms) {
|
||||
return (
|
||||
<EmptyState icon="alert" title="Could not load the forms">
|
||||
Form definitions were missing from the server response.
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
const summary = formsQuery.data?.summary ?? null
|
||||
const offers = offersQuery.data?.data ?? []
|
||||
// Spread into create payloads — exactly one key, matching the backend XOR.
|
||||
const link = inboxId
|
||||
? { inbox_id: Number(inboxId) }
|
||||
: { manual_upload_candidate_id: manualId }
|
||||
const link = {
|
||||
...(inboxId
|
||||
? { inbox_id: Number(inboxId) }
|
||||
: { manual_upload_candidate_id: manualId }),
|
||||
...(live?.assigned_job_post_id ? { job_post_id: live.assigned_job_post_id } : {}),
|
||||
}
|
||||
|
||||
const done = {
|
||||
requisition: rows.some((r) => r.form_type === 'requisition'),
|
||||
interview_analysis: rows.some((r) => r.form_type === 'interview_analysis'),
|
||||
cultural_fit: rows.some((r) => r.form_type === 'cultural_fit'),
|
||||
offer: offers.length > 0,
|
||||
}
|
||||
const segTabs = [
|
||||
{ key: 'requisition', label: 'Requisition' },
|
||||
{ key: 'interview_analysis', label: 'Interview Analysis' },
|
||||
{ key: 'cultural_fit', label: 'Cultural Fit' },
|
||||
{ key: 'offer', label: 'Offer' },
|
||||
...(!isManager ? [{ key: 'offer', label: 'Offer' }] : []),
|
||||
]
|
||||
|
||||
const evalCount = rows.filter(
|
||||
|
|
@ -165,21 +191,6 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{seg === 'requisition' && (
|
||||
<RequisitionForm
|
||||
// Remount when the saved row appears/changes so the editor flips from
|
||||
// create to amend mode (state is seeded on mount only).
|
||||
key={rows.find((r) => r.form_type === 'requisition')?.id ?? 'new'}
|
||||
def={defs.forms.requisition}
|
||||
defs={defs}
|
||||
rows={rows.filter((r) => r.form_type === 'requisition')}
|
||||
userId={userId}
|
||||
link={link}
|
||||
live={live}
|
||||
canCreate={can('interviews.create')}
|
||||
canEdit={can('interviews.edit')}
|
||||
/>
|
||||
)}
|
||||
{(seg === 'interview_analysis' || seg === 'cultural_fit') && (
|
||||
<RatedEvaluationForm
|
||||
key={seg}
|
||||
|
|
@ -194,7 +205,7 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
canEdit={can('interviews.edit')}
|
||||
/>
|
||||
)}
|
||||
{seg === 'offer' && (
|
||||
{seg === 'offer' && !isManager && (
|
||||
<OfferSection userId={userId} inboxId={inboxId} live={live} offersQuery={offersQuery} />
|
||||
)}
|
||||
</>
|
||||
|
|
@ -203,19 +214,18 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
|
||||
/* ------------------------------------------------------------------
|
||||
Annexure E's OVERALL SCORE SUMMARY — three section tiles plus the combined
|
||||
overall as the hero. Values are magnitudes on a fixed 1–4 scale, so each
|
||||
tile carries a thin single-hue meter; numbers stay in text ink. */
|
||||
overall as the hero. Ticks and averages are 25/50/75/100 percentages. */
|
||||
|
||||
function ScoreTile({ label, value, hero, sub }) {
|
||||
const pct = value != null ? Math.max(0, Math.min(100, (value / 4) * 100)) : 0
|
||||
const pct = toPercent(value)
|
||||
return (
|
||||
<div className={`hf-tile${hero ? ' hero' : ''}`}>
|
||||
<div className="hf-k" title={label}>{label}</div>
|
||||
<div className="hf-v">
|
||||
{value != null ? value : '—'}
|
||||
{value != null && <small>/ 4</small>}
|
||||
{pct != null ? pct : '—'}
|
||||
{pct != null && <small>%</small>}
|
||||
</div>
|
||||
<div className="hf-meter"><i style={{ width: `${pct}%` }} /></div>
|
||||
<div className="hf-meter"><i style={{ width: `${pct ?? 0}%` }} /></div>
|
||||
{sub && <div className="hf-sub">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -252,7 +262,7 @@ function fieldLabel(def, key) {
|
|||
}
|
||||
|
||||
function sectionAverage(ratings) {
|
||||
const values = Object.values(ratings).filter((v) => v != null)
|
||||
const values = Object.values(ratings).map(toPercent).filter((v) => v != null)
|
||||
if (!values.length) return null
|
||||
return Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100
|
||||
}
|
||||
|
|
@ -271,7 +281,7 @@ function FormRowList({ rows, defs, onEdit, canEdit }) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="lr-right" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{r.overall_score != null && <Badge>{r.overall_score} / 4</Badge>}
|
||||
{r.overall_score != null && <Badge>{formatPercent(r.overall_score)}</Badge>}
|
||||
{r.recommendation && (
|
||||
<Badge className="b-gray">
|
||||
{defs.recommendation_labels[r.recommendation] ?? r.recommendation}
|
||||
|
|
@ -292,26 +302,29 @@ function FormRowList({ rows, defs, onEdit, canEdit }) {
|
|||
/* The paper's rating grid: scale header, one radio-dot per cell, average foot. */
|
||||
function RatingTable({ section, defs, ratings, onRate }) {
|
||||
const average = sectionAverage(ratings)
|
||||
const points = Array.isArray(defs.rating_points) && defs.rating_points.length
|
||||
? defs.rating_points
|
||||
: RATING_POINTS
|
||||
return (
|
||||
<div className="hf-rate">
|
||||
<div className="hf-rate-head">
|
||||
<div>Criteria</div>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
{points.map((n) => (
|
||||
<div key={n}>
|
||||
<span className="hf-scale-full">{defs.rating_labels[String(n)]}</span>
|
||||
<span className="hf-scale-short" title={defs.rating_labels[String(n)]}>{n}</span>
|
||||
<span className="hf-scale-full">{defs.rating_labels[String(n)] ?? formatPercent(n)}</span>
|
||||
<span className="hf-scale-short" title={defs.rating_labels[String(n)]}>{formatPercent(n)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{section.criteria.map((c) => (
|
||||
<div className="hf-rate-row" key={c.key}>
|
||||
<div>{c.label}</div>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
{points.map((n) => (
|
||||
<div className="hf-rate-cell" key={n}>
|
||||
<button
|
||||
type="button"
|
||||
className={`hf-dot${ratings[c.key] === n ? ' on' : ''}`}
|
||||
aria-label={`${c.label}: ${defs.rating_labels[String(n)]}`}
|
||||
className={`hf-dot${Number(ratings[c.key]) === Number(n) ? ' on' : ''}`}
|
||||
aria-label={`${c.label}: ${defs.rating_labels[String(n)] ?? formatPercent(n)}`}
|
||||
onClick={() => onRate(c.key, n)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -320,7 +333,7 @@ function RatingTable({ section, defs, ratings, onRate }) {
|
|||
))}
|
||||
<div className="hf-rate-foot">
|
||||
<div>{section.average_label || 'Section average'}</div>
|
||||
<div className="hf-avg">{average ?? '—'}</div>
|
||||
<div className="hf-avg">{formatPercent(average) ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -359,7 +372,7 @@ function RatedEvaluationForm({ formType, def, defs, rows, userId, link, live, ca
|
|||
}
|
||||
for (const s of editing.sections ?? []) {
|
||||
for (const c of s.criteria ?? []) {
|
||||
if (ratings[s.key] && c.key in ratings[s.key]) ratings[s.key][c.key] = c.rating ?? null
|
||||
if (ratings[s.key] && c.key in ratings[s.key]) ratings[s.key][c.key] = toPercent(c.rating)
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
|
@ -422,7 +435,10 @@ function EvaluationEditor({ formType, def, defs, row, initial, userId, link, liv
|
|||
const setRating = (sectionKey, critKey, value) =>
|
||||
setRatings((r) => ({
|
||||
...r,
|
||||
[sectionKey]: { ...r[sectionKey], [critKey]: r[sectionKey][critKey] === value ? null : value },
|
||||
[sectionKey]: {
|
||||
...r[sectionKey],
|
||||
[critKey]: Number(r[sectionKey][critKey]) === Number(value) ? null : value,
|
||||
},
|
||||
}))
|
||||
|
||||
const save = useFormsWrite({
|
||||
|
|
@ -570,270 +586,6 @@ function EvaluationEditor({ formType, def, defs, row, initial, userId, link, liv
|
|||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Annexure A — Employee Requisition. One form per application (the latest row
|
||||
is loaded for amendment); the approval chain is typed name + date, not a
|
||||
workflow engine. */
|
||||
|
||||
const SIGN_SLOTS = [
|
||||
{ nameKey: 'initiated_by', dateKey: 'initiated_date', role: 'Initiated By' },
|
||||
{ nameKey: 'recommended_by', dateKey: 'recommended_date', role: 'Recommended By · Director' },
|
||||
{ nameKey: 'approved_by', dateKey: 'approved_date', role: 'Approved By · Director HR' },
|
||||
{ nameKey: 'vp_approved_by', dateKey: 'vp_approved_date', role: 'Approved By · VP/SVP' },
|
||||
]
|
||||
|
||||
function RequisitionForm({ def, defs, rows, userId, link, live, canCreate, canEdit }) {
|
||||
const row = rows[0] ?? null
|
||||
const allowed = row ? canEdit : canCreate
|
||||
|
||||
const initial = useMemo(() => {
|
||||
const fields = {}
|
||||
for (const f of def.fields) {
|
||||
const saved = row?.fields?.[f.key]
|
||||
if (f.kind === 'bool') fields[f.key] = saved === true ? 'yes' : saved === false ? 'no' : ''
|
||||
else fields[f.key] = saved != null ? String(saved) : ''
|
||||
}
|
||||
if (!fields.job_title) fields.job_title = row ? '' : live?.job_title || ''
|
||||
return { fields, date: toDateInput(row?.form_date) || toDateInput(new Date().toISOString()) }
|
||||
}, [def, row, live])
|
||||
|
||||
const [fields, setFields] = useState(initial.fields)
|
||||
const [date, setDate] = useState(initial.date)
|
||||
const [errors, setErrors] = useState({})
|
||||
const set = (k, v) => setFields((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const save = useFormsWrite({
|
||||
userId,
|
||||
mutationFn: () => {
|
||||
const payload = {}
|
||||
for (const f of def.fields) {
|
||||
const value = fields[f.key]
|
||||
if (f.kind === 'bool') payload[f.key] = value === '' ? null : value === 'yes'
|
||||
else payload[f.key] = value === '' ? null : value
|
||||
}
|
||||
const body = {
|
||||
form_date: date ? new Date(`${date}T00:00`).toISOString() : null,
|
||||
fields: payload,
|
||||
}
|
||||
if (row) return formsApi.update(row.id, body)
|
||||
return formsApi.create({ form_type: 'requisition', ...link, ...body })
|
||||
},
|
||||
success: row ? 'Requisition form updated' : 'Requisition form saved',
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const next = {}
|
||||
if (!fields.job_title.trim()) next.job_title = 'Enter the job title'
|
||||
if (fields.jd_available === 'no') {
|
||||
next.jd_available = 'JD is mandatory — the TA team will not proceed without it'
|
||||
}
|
||||
setErrors(next)
|
||||
if (Object.keys(next).length) return
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
const notPermanent = fields.employment_type && fields.employment_type !== 'permanent'
|
||||
const label = (key) => fieldLabel(def, key)
|
||||
|
||||
return (
|
||||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||||
<div className="hf-block">
|
||||
<div className="hf-block-title">Position Request</div>
|
||||
<div className="hf-note">{def.header_note}</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>{label('department')}</label>
|
||||
<input value={fields.department} onChange={(e) => set('department', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('job_title')} <span className="req">*</span></label>
|
||||
<input
|
||||
className={errors.job_title ? 'err' : ''}
|
||||
value={fields.job_title}
|
||||
onChange={(e) => set('job_title', e.target.value)}
|
||||
/>
|
||||
<FieldError>{errors.job_title}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Date</label>
|
||||
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('date_needed')}</label>
|
||||
<input type="date" value={fields.date_needed} onChange={(e) => set('date_needed', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('employment_type')}</label>
|
||||
<select value={fields.employment_type} onChange={(e) => set('employment_type', e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{defs.employment_types.map((t) => (
|
||||
<option key={t} value={t}>{defs.employment_type_labels[t]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('jd_available')}</label>
|
||||
<select
|
||||
className={errors.jd_available ? 'err' : ''}
|
||||
value={fields.jd_available}
|
||||
onChange={(e) => set('jd_available', e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
<FieldError>{errors.jd_available}</FieldError>
|
||||
</div>
|
||||
{notPermanent && (
|
||||
<>
|
||||
<div className="form-field">
|
||||
<label>{label('period_from')}</label>
|
||||
<input type="date" value={fields.period_from} onChange={(e) => set('period_from', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('period_to')}</label>
|
||||
<input type="date" value={fields.period_to} onChange={(e) => set('period_to', e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hf-block">
|
||||
<div className="hf-block-title">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.is_replacement === 'yes'}
|
||||
onChange={(e) => set('is_replacement', e.target.checked ? 'yes' : '')}
|
||||
/>
|
||||
{label('is_replacement')}
|
||||
</label>
|
||||
</div>
|
||||
{fields.is_replacement === 'yes' && (
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>{label('replacement_employee')}</label>
|
||||
<input
|
||||
value={fields.replacement_employee}
|
||||
onChange={(e) => set('replacement_employee', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('replacement_grade')}</label>
|
||||
<input
|
||||
value={fields.replacement_grade}
|
||||
onChange={(e) => set('replacement_grade', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('replacement_job_title')}</label>
|
||||
<input
|
||||
value={fields.replacement_job_title}
|
||||
onChange={(e) => set('replacement_job_title', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('replacement_date_separated')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={fields.replacement_date_separated}
|
||||
onChange={(e) => set('replacement_date_separated', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hf-block">
|
||||
<div className="hf-block-title">New / Additional Headcount</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>{label('headcount_justification')}</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
style={{ minHeight: 56 }}
|
||||
value={fields.headcount_justification}
|
||||
onChange={(e) => set('headcount_justification', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('proposed_budget')}</label>
|
||||
<input value={fields.proposed_budget} onChange={(e) => set('proposed_budget', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('recommended_grade')}</label>
|
||||
<input value={fields.recommended_grade} onChange={(e) => set('recommended_grade', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hf-block">
|
||||
<div className="hf-block-title">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.internal_recommendation === 'yes'}
|
||||
onChange={(e) => set('internal_recommendation', e.target.checked ? 'yes' : '')}
|
||||
/>
|
||||
{label('internal_recommendation')}
|
||||
</label>
|
||||
</div>
|
||||
{fields.internal_recommendation === 'yes' && (
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>{label('recommended_employee_name')}</label>
|
||||
<input
|
||||
value={fields.recommended_employee_name}
|
||||
onChange={(e) => set('recommended_employee_name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>{label('recommended_employee_department')}</label>
|
||||
<input
|
||||
value={fields.recommended_employee_department}
|
||||
onChange={(e) => set('recommended_employee_department', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hf-block">
|
||||
<div className="hf-block-title">Approvals</div>
|
||||
<div className="hf-sign-grid">
|
||||
{SIGN_SLOTS.map((slot) => (
|
||||
<div key={slot.nameKey} className="hf-sign">
|
||||
<span className="hf-sign-role">{slot.role}</span>
|
||||
<input
|
||||
placeholder="Name"
|
||||
value={fields[slot.nameKey]}
|
||||
onChange={(e) => set(slot.nameKey, e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={fields[slot.dateKey]}
|
||||
onChange={(e) => set(slot.dateKey, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8" style={{ marginTop: 14 }}>
|
||||
<button className="btn btn-primary btn-sm" disabled={!allowed || save.isPending} type="submit">
|
||||
{save.isPending ? 'Saving…' : row ? 'Save Changes' : 'Save Form'}
|
||||
</button>
|
||||
{row && (
|
||||
<span className="text-muted" style={{ fontSize: 12 }}>
|
||||
Filed {toDateInput(row.created_at)} by {row.created_by_name || 'unknown'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Annexure J — the offer email's remuneration table, written onto the existing
|
||||
offers record (drafted here, ISSUED from the Offers screen). */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* The 9-tab candidate profile modal, split out of Candidates.jsx — it was the
|
||||
/* The candidate profile modal, split out of Candidates.jsx — it was the
|
||||
single largest block in js/candidates.js and deserves its own file.
|
||||
|
||||
TWO DATA MODES, selected by whether the caller passes a `userId`:
|
||||
|
|
@ -7,8 +7,8 @@
|
|||
the prototype did.
|
||||
LIVE (TalentPool.jsx) — GET /candidate/fetch?user_id= switches the endpoint
|
||||
into detail mode and returns the real record: résumé text, the agent's
|
||||
match verdict, documents, and the four child collections (interviews,
|
||||
notes, activity, feedback). The write tabs POST to their own endpoints
|
||||
match verdict, documents, and the child collections (interviews,
|
||||
notes, activity). The write tabs POST to their own endpoints
|
||||
and invalidate this one query, so the whole modal repaints from a single
|
||||
refetch. History is fetched separately (GET /candidate/history/fetch)
|
||||
when that tab opens — it is append-only and not part of the detail payload.
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
worse than showing none.
|
||||
|
||||
Scoping differs between the child tables and is not interchangeable: notes
|
||||
hang off the candidate (users.id), while interviews, activity and feedback
|
||||
hang off the candidate (users.id), while interviews and activity
|
||||
hang off one application (inbox.id). */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
|
@ -30,6 +30,7 @@ import { Tabs } from '../ui/Tabs'
|
|||
import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
|
|
@ -41,12 +42,11 @@ import CandidateFormsTab from './CandidateForms'
|
|||
import { companies, fmtDate, moneyK, pick } from '../data/seed'
|
||||
|
||||
/* Workflow order: learn (Overview, Resume, Documents) → interview (Interview,
|
||||
Forms, Feedback) → track (Notes, Activity) → audit (Timeline, History). */
|
||||
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History']
|
||||
Forms) → track (Notes, Activity) → audit (Timeline, History). */
|
||||
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', 'Timeline', 'History']
|
||||
// Forward progression for the live Advance button. Rejected has no next stage.
|
||||
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
|
||||
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
||||
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
|
||||
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
|
||||
|
|
@ -126,10 +126,11 @@ export default function CandidateProfile({
|
|||
variant = 'modal',
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const { can } = useAuth()
|
||||
const [tab, setTab] = useState('Overview')
|
||||
const { can, user } = useAuth()
|
||||
const isManager = isHiringManager(user)
|
||||
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
|
||||
const [tab, setTab] = useState(isManager ? 'Forms' : 'Overview')
|
||||
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
|
||||
const isLive = Boolean(c.userId)
|
||||
const detail = useCandidateDetail(c.userId)
|
||||
|
|
@ -226,11 +227,10 @@ export default function CandidateProfile({
|
|||
|
||||
const counts = live && {
|
||||
Interview: live.interviews?.length ?? 0,
|
||||
Forms: formsQuery.data?.total ?? 0,
|
||||
Forms: (formsQuery.data?.data ?? []).filter((r) => r.form_type !== 'requisition').length,
|
||||
Notes: live.notes?.length ?? 0,
|
||||
Activity: live.activity?.length ?? 0,
|
||||
Documents: live.documents?.length ?? 0,
|
||||
Feedback: live.feedback?.length ?? 0,
|
||||
}
|
||||
|
||||
// In live mode nothing below the hero can be trusted until the detail payload
|
||||
|
|
@ -247,7 +247,7 @@ export default function CandidateProfile({
|
|||
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
|
||||
) : null
|
||||
|
||||
const actions = (
|
||||
const actions = isManager ? null : (
|
||||
<>
|
||||
<button
|
||||
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
|
||||
|
|
@ -338,7 +338,7 @@ export default function CandidateProfile({
|
|||
value={tab}
|
||||
onChange={setTab}
|
||||
className="tabs tabs-wrap"
|
||||
tabs={TABS.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
|
||||
tabs={visibleTabs.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -605,37 +605,6 @@ export default function CandidateProfile({
|
|||
))}
|
||||
</div>
|
||||
)))}
|
||||
|
||||
{tab === 'Feedback' && (guard || (live ? (
|
||||
<FeedbackTab userId={c.userId} inboxId={inboxId} rows={live.feedback ?? []} />
|
||||
) : (
|
||||
<>
|
||||
<div className="list-tight">
|
||||
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => {
|
||||
const r = recruiters[i]
|
||||
if (!r) return null
|
||||
const notes = [
|
||||
'Excellent technical depth and clear communication.',
|
||||
'Good problem solving, would benefit from more system design exposure.',
|
||||
'Solid candidate, positive team energy.',
|
||||
]
|
||||
return (
|
||||
<div className="list-row" key={score}>
|
||||
<Avatar name={r.name} initials={r.initials} color={r.color} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{r.name}</div>
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{notes[i]}</div>
|
||||
</div>
|
||||
<div className="lr-right"><Badge>{score}</Badge></div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" style={{ marginTop: 14 }} onClick={() => toast('Scorecard form opened', 'info')}>
|
||||
<Icon name="plus" /> Submit Scorecard
|
||||
</button>
|
||||
</>
|
||||
)))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
@ -650,7 +619,7 @@ export default function CandidateProfile({
|
|||
<div className="cand-page-crumb">
|
||||
Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
|
||||
</div>
|
||||
<div className="cand-page-actions">{actions}</div>
|
||||
{actions && <div className="cand-page-actions">{actions}</div>}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-body">{body}</div>
|
||||
|
|
@ -1241,198 +1210,3 @@ function DocumentsTab({ rows, inboxId, manualUploadCandidateId }) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One scorecard, revisable in place via PATCH /feedback/update.
|
||||
*
|
||||
* Same authorship rule as NoteRow: the route neither checks nor reassigns
|
||||
* `reviewed_by`, so only the original reviewer is offered the control. A
|
||||
* revision keeps their name on it, which is the point.
|
||||
*/
|
||||
function FeedbackRow({ row: f, userId }) {
|
||||
const { user } = useAuth()
|
||||
const { toast } = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
review: f.review || REVIEWS[0],
|
||||
score: f.score == null ? '' : String(f.score),
|
||||
note: f.note || '',
|
||||
})
|
||||
const set = (k, v) => setForm((s) => ({ ...s, [k]: v }))
|
||||
|
||||
const mine = Boolean(user?.id && f.reviewed_by && String(user.id) === String(f.reviewed_by))
|
||||
|
||||
const save = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.updateFeedback(f.id, {
|
||||
review: form.review,
|
||||
score: form.score === '' ? 0 : Number(form.score),
|
||||
note: form.note.trim(),
|
||||
}),
|
||||
success: 'Scorecard updated',
|
||||
onDone: () => setEditing(false),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const score = form.score === '' ? 0 : Number(form.score)
|
||||
if (!Number.isFinite(score) || score < 0 || score > 100) {
|
||||
toast('Score must be between 0 and 100', 'warning')
|
||||
return
|
||||
}
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="list-row" style={{ alignItems: 'flex-start' }}>
|
||||
<Avatar name={f.reviewed_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Recommendation</label>
|
||||
<select value={form.review} onChange={(e) => set('review', e.target.value)}>
|
||||
{REVIEWS.map((r) => <option key={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Score (0–100)</label>
|
||||
<input
|
||||
type="number" min="0" max="100"
|
||||
value={form.score}
|
||||
onChange={(e) => set('score', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Notes</label>
|
||||
<textarea value={form.note} onChange={(e) => set('note', e.target.value)} rows={3} />
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<button className="btn btn-primary btn-sm" disabled={save.isPending} onClick={submit}>
|
||||
{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={save.isPending}
|
||||
onClick={() => {
|
||||
setForm({
|
||||
review: f.review || REVIEWS[0],
|
||||
score: f.score == null ? '' : String(f.score),
|
||||
note: f.note || '',
|
||||
})
|
||||
setEditing(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="list-row">
|
||||
<Avatar name={f.reviewed_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{f.reviewed_by_name || 'Unknown reviewer'}</div>
|
||||
{f.note && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{f.note}</div>}
|
||||
<div className="lr-sub">
|
||||
{fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}
|
||||
{f.updated_at && f.updated_at !== f.created_at ? ' · revised' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lr-right" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{f.review ? <Badge>{f.review}</Badge> : null}
|
||||
{mine && (
|
||||
<button className="act-btn" data-tip="Revise scorecard" aria-label="Revise scorecard" onClick={() => setEditing(true)}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedbackTab({ userId, inboxId, rows }) {
|
||||
const { toast } = useToast()
|
||||
const [form, setForm] = useState({ review: REVIEWS[0], score: '', note: '' })
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const create = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.createFeedback({
|
||||
inboxId,
|
||||
review: form.review,
|
||||
score: form.score === '' ? 0 : Number(form.score),
|
||||
note: form.note.trim(),
|
||||
}),
|
||||
success: 'Scorecard submitted',
|
||||
onDone: () => setForm({ review: REVIEWS[0], score: '', note: '' }),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const score = form.score === '' ? 0 : Number(form.score)
|
||||
if (!Number.isFinite(score) || score < 0 || score > 100) {
|
||||
toast('Score must be between 0 and 100', 'warning')
|
||||
return
|
||||
}
|
||||
create.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((f) => <FeedbackRow key={f.id} row={f} userId={userId} />)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="award" title="No scorecards yet">Be the first to review this candidate.</EmptyState>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
<h3 className="form-section-title" style={{ marginTop: 0 }}>Submit a scorecard</h3>
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Recommendation</label>
|
||||
<select value={form.review} onChange={(e) => set('review', e.target.value)}>
|
||||
{REVIEWS.map((r) => <option key={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Score (0–100)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={form.score}
|
||||
onChange={(e) => set('score', e.target.value)}
|
||||
placeholder="80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Notes</label>
|
||||
<textarea
|
||||
value={form.note}
|
||||
onChange={(e) => set('note', e.target.value)}
|
||||
placeholder="What stood out, and what would you probe next round?"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ marginTop: 10 }}
|
||||
disabled={!inboxId || create.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Submitting…' : 'Submit Scorecard'}
|
||||
</button>
|
||||
{!inboxId && (
|
||||
<p className="text-muted" style={{ marginTop: 8, fontSize: 12.5 }}>
|
||||
Scorecards attach to an email application — this candidate was added manually,
|
||||
so submitting is unavailable here.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* ============================================================
|
||||
/* ============================================================
|
||||
Candidates — the scored-candidate pool, on live backend data.
|
||||
|
||||
Rows come from GET /candidate/fetch (all jobs) via the shared
|
||||
|
|
@ -14,10 +14,12 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
|
||||
import DataTable, { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
import CandidateProfile from './CandidateProfile'
|
||||
import { useJobTitles } from './ScoredCandidateProfile'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
|
|
@ -106,7 +108,134 @@ const REFERRAL_RE = new RegExp(
|
|||
*/
|
||||
const referralValue = (raw) => (raw || '').trim().toLowerCase()
|
||||
|
||||
const STAGE_BADGE = {
|
||||
Shortlist: 'b-indigo',
|
||||
Screening: 'b-teal',
|
||||
Assessment: 'b-purple',
|
||||
Interview: 'b-amber',
|
||||
Offer: 'b-green',
|
||||
Approved: 'b-green',
|
||||
Hired: 'b-green',
|
||||
'On Hold': 'b-amber',
|
||||
Rejected: 'b-gray',
|
||||
}
|
||||
|
||||
export default function Candidates() {
|
||||
const { user } = useAuth()
|
||||
if (isHiringManager(user)) return <HiringManagerCandidates />
|
||||
return <RecruiterCandidates />
|
||||
}
|
||||
|
||||
function HiringManagerCandidates() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const id = location.state?.openCandidate
|
||||
if (id) navigate(`/candidate/${id}`, { replace: true })
|
||||
}, [location.state, navigate])
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.candidates.managerList(),
|
||||
queryFn: async () => {
|
||||
const res = await candidatesApi.listForManager({ limit: 200, offset: 0 })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
})
|
||||
const rowsAll = listQuery.data ?? []
|
||||
const rows = useMemo(() => {
|
||||
if (!q.trim()) return rowsAll
|
||||
const needle = q.trim().toLowerCase()
|
||||
return rowsAll.filter((r) => {
|
||||
const hay = [r.name, r.email, r.job_title].filter(Boolean).join(' ').toLowerCase()
|
||||
return hay.includes(needle)
|
||||
})
|
||||
}, [rowsAll, q])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Candidate',
|
||||
sortable: true,
|
||||
sortValue: (r) => r.name || '',
|
||||
render: (r) => (
|
||||
<>
|
||||
<div className="cell-primary">{r.name || '—'}</div>
|
||||
<div className="cell-sub">{r.email || '—'}</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'job',
|
||||
label: 'Job',
|
||||
sortable: true,
|
||||
sortValue: (r) => r.job_title || '',
|
||||
render: (r) => r.job_title || '—',
|
||||
},
|
||||
{
|
||||
key: 'stage',
|
||||
label: 'Stage',
|
||||
sortable: true,
|
||||
sortValue: (r) => r.application_status || '',
|
||||
render: (r) => {
|
||||
const status = String(r.application_status || '').toUpperCase()
|
||||
const stage = pipelineApi.STAGE_FROM_STATUS[status] ?? 'Shortlist'
|
||||
return <Badge className={STAGE_BADGE[stage] || ''}>{stage}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'applied',
|
||||
label: 'Allocated',
|
||||
sortable: true,
|
||||
sortValue: (r) => r.created_at || '',
|
||||
render: (r) => (
|
||||
<span className="text-muted">
|
||||
{r.created_at ? fmtDate(new Date(r.created_at)) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Candidates"
|
||||
sub={`${rowsAll.length} candidate${rowsAll.length === 1 ? '' : 's'} on your requisition jobs`}
|
||||
/>
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="toolbar" style={{ marginBottom: 14 }}>
|
||||
<div className="toolbar-search" style={{ flex: 1, maxWidth: 360 }}>
|
||||
<Icon name="search" />
|
||||
<input
|
||||
placeholder="Search name, email, or job…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{listQuery.isPending && <SkeletonRows rows={4} />}
|
||||
{listQuery.isError && (
|
||||
<EmptyState icon="users" title="Couldn’t load candidates">
|
||||
{friendlyAuthError(listQuery.error, 'Please try again.')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{listQuery.isSuccess && (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
empty="No candidates are allocated to jobs opened from your requisitions yet."
|
||||
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecruiterCandidates() {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const location = useLocation()
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import Chart from '../ui/Chart'
|
||||
|
|
@ -32,6 +32,7 @@ import ChartCard, { widgetError } from '../ui/ChartCard'
|
|||
import PageHeader from '../ui/PageHeader'
|
||||
import { Badge, EmptyState, Icon, KpiTile } from '../ui/primitives'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges'
|
||||
import { fmtShort, money } from '../data/seed'
|
||||
|
|
@ -134,6 +135,12 @@ function ListGate({ query, title, permission, children, emptyTitle, emptyHint })
|
|||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth()
|
||||
if (isHiringManager(user)) return <Navigate to="/candidates" replace />
|
||||
return <DashboardHome />
|
||||
}
|
||||
|
||||
function DashboardHome() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const firstName = (user?.name || 'there').split(' ')[0]
|
||||
|
|
@ -419,11 +426,11 @@ export default function Dashboard() {
|
|||
className="jobapp-row"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate('/jobs', { state: { openJob: j.job_post_id } })}
|
||||
onClick={() => navigate(`/progress?job=${encodeURIComponent(j.job_post_id)}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate('/jobs', { state: { openJob: j.job_post_id } })
|
||||
navigate(`/progress?job=${encodeURIComponent(j.job_post_id)}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import AiFieldAssist from '../ui/AiFieldAssist'
|
||||
import DataTable from '../ui/DataTable'
|
||||
|
|
@ -28,6 +28,7 @@ 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 * as requisitionsApi from '../api/requisitions'
|
||||
import { empTypes, fmtShort } from '../data/seed'
|
||||
|
||||
// Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list).
|
||||
|
|
@ -140,6 +141,7 @@ export default function Jobs() {
|
|||
},
|
||||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
setCreating(false)
|
||||
if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error')
|
||||
else toast('Job created', 'success')
|
||||
|
|
@ -148,6 +150,7 @@ export default function Jobs() {
|
|||
// 502: row was created but Buffer publish failed — refresh the board and
|
||||
// say so; a flat "create failed" toast would be wrong.
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
if (err?.status === 502) {
|
||||
setCreating(false)
|
||||
toast('Job created, but publishing failed — see its status on the board.', 'error')
|
||||
|
|
@ -161,6 +164,7 @@ export default function Jobs() {
|
|||
mutationFn: ({ id, body }) => jobsApi.update(id, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
setEditing(null)
|
||||
toast('Job updated', 'success')
|
||||
},
|
||||
|
|
@ -180,6 +184,7 @@ export default function Jobs() {
|
|||
mutationFn: (id) => jobsApi.remove(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
setViewing(null)
|
||||
toast('Job deleted', 'success')
|
||||
},
|
||||
|
|
@ -422,6 +427,7 @@ function SearchSelect({
|
|||
allowEmpty = false,
|
||||
emptyLabel = 'Unassigned',
|
||||
error = false,
|
||||
onQueryChange,
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -436,12 +442,19 @@ function SearchSelect({
|
|||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onQueryChange || !open) return
|
||||
onQueryChange(q)
|
||||
}, [q, open, onQueryChange])
|
||||
|
||||
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)
|
||||
})
|
||||
const filtered = onQueryChange
|
||||
? options
|
||||
: 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%' }}>
|
||||
|
|
@ -507,12 +520,52 @@ function useRecruiterDirectory() {
|
|||
})
|
||||
}
|
||||
|
||||
function useRequisitionPicker(initialPicked = null, jobPostId = null) {
|
||||
const [reqQ, setReqQ] = useState('')
|
||||
const [debouncedReqQ, setDebouncedReqQ] = useState('')
|
||||
const [pickedReq, setPickedReq] = useState(initialPicked)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedReqQ(reqQ.trim()), 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [reqQ])
|
||||
|
||||
const requisitionsQuery = useQuery({
|
||||
queryKey: qk.requisitions.search(debouncedReqQ, jobPostId),
|
||||
queryFn: async () => {
|
||||
const res = await requisitionsApi.search({ q: debouncedReqQ, jobPostId })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.label,
|
||||
title: r.title || '',
|
||||
department: r.department || '',
|
||||
}))
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const requisitionOptions = useMemo(() => {
|
||||
const rows = requisitionsQuery.data ?? []
|
||||
if (pickedReq && !rows.some((o) => String(o.id) === String(pickedReq.id))) {
|
||||
return [pickedReq, ...rows]
|
||||
}
|
||||
return rows
|
||||
}, [requisitionsQuery.data, pickedReq])
|
||||
|
||||
return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq }
|
||||
}
|
||||
|
||||
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
|
||||
|
||||
const form = useFormState({
|
||||
hiring_manager_id: '',
|
||||
current_recruiter_id: '',
|
||||
requisition_id: '',
|
||||
title: '',
|
||||
department: '',
|
||||
location: '',
|
||||
|
|
@ -568,7 +621,6 @@ 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)
|
||||
|
|
@ -599,8 +651,9 @@ 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,
|
||||
hiring_manager_id: v.hiring_manager_id || undefined,
|
||||
current_recruiter_id: v.current_recruiter_id || undefined,
|
||||
requisition_id: v.requisition_id || undefined,
|
||||
}, imageFile)
|
||||
}
|
||||
|
||||
|
|
@ -652,6 +705,36 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
>
|
||||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>Requisition</label>
|
||||
<SearchSelect
|
||||
options={requisitionOptions}
|
||||
value={form.values.requisition_id}
|
||||
onChange={(id) => {
|
||||
form.setField('requisition_id', id)
|
||||
if (!id) {
|
||||
setPickedReq(null)
|
||||
return
|
||||
}
|
||||
const opt = requisitionOptions.find((o) => String(o.id) === String(id))
|
||||
if (opt) {
|
||||
setPickedReq(opt)
|
||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
||||
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
|
||||
}
|
||||
}}
|
||||
onQueryChange={setReqQ}
|
||||
placeholder="Search by job title or department…"
|
||||
disabled={busy}
|
||||
loading={requisitionsQuery.isPending && !requisitionsQuery.data}
|
||||
allowEmpty
|
||||
emptyLabel="No requisition"
|
||||
/>
|
||||
{requisitionsQuery.isError && (
|
||||
<p className="text-muted text-sm">Could not load requisitions.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-field col-span-2">
|
||||
<div className="field-label-row">
|
||||
<label>Title <span className="req">*</span></label>
|
||||
|
|
@ -662,7 +745,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Hiring manager <span className="req">*</span></label>
|
||||
<label>Hiring manager</label>
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
value={form.values.hiring_manager_id}
|
||||
|
|
@ -670,9 +753,9 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
placeholder="Search hiring managers…"
|
||||
disabled={busy}
|
||||
loading={managersQuery.isPending}
|
||||
error={Boolean(form.errors.hiring_manager_id)}
|
||||
allowEmpty
|
||||
emptyLabel="Unassigned"
|
||||
/>
|
||||
<FieldError>{form.errors.hiring_manager_id}</FieldError>
|
||||
{managersQuery.isError && (
|
||||
<p className="text-muted text-sm">Could not load hiring managers.</p>
|
||||
)}
|
||||
|
|
@ -845,7 +928,19 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
|
||||
j.requisitionId
|
||||
? {
|
||||
id: j.requisitionId,
|
||||
name: j.requisitionLabel || 'Linked requisition',
|
||||
title: j.requisitionTitle || '',
|
||||
department: j.requisitionDepartment || '',
|
||||
}
|
||||
: null,
|
||||
j.id,
|
||||
)
|
||||
const form = useFormState({
|
||||
requisition_id: j.requisitionId || '',
|
||||
title: j.title || '',
|
||||
department: j.department || '',
|
||||
location: j.location || '',
|
||||
|
|
@ -885,7 +980,6 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
const title = form.values.title.trim()
|
||||
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({
|
||||
|
|
@ -897,8 +991,9 @@ 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,
|
||||
hiring_manager_id: form.values.hiring_manager_id || null,
|
||||
current_recruiter_id: form.values.current_recruiter_id || null,
|
||||
requisition_id: form.values.requisition_id || null,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -919,6 +1014,35 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
>
|
||||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>Requisition</label>
|
||||
<SearchSelect
|
||||
options={requisitionOptions}
|
||||
value={form.values.requisition_id}
|
||||
onChange={(id) => {
|
||||
form.setField('requisition_id', id)
|
||||
if (!id) {
|
||||
setPickedReq(null)
|
||||
return
|
||||
}
|
||||
const opt = requisitionOptions.find((o) => String(o.id) === String(id))
|
||||
if (opt) {
|
||||
setPickedReq(opt)
|
||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
||||
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
|
||||
}
|
||||
}}
|
||||
onQueryChange={setReqQ}
|
||||
placeholder="Search by job title or department…"
|
||||
disabled={busy}
|
||||
loading={requisitionsQuery.isPending && !requisitionsQuery.data}
|
||||
allowEmpty
|
||||
emptyLabel="No requisition"
|
||||
/>
|
||||
{requisitionsQuery.isError && (
|
||||
<p className="text-muted text-sm">Could not load requisitions.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<div className="field-label-row">
|
||||
<label>Title</label>
|
||||
|
|
@ -928,7 +1052,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
<FieldError>{form.errors.title}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Hiring manager <span className="req">*</span></label>
|
||||
<label>Hiring manager</label>
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
value={form.values.hiring_manager_id}
|
||||
|
|
@ -936,9 +1060,9 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
placeholder="Search hiring managers…"
|
||||
disabled={busy}
|
||||
loading={managersQuery.isPending}
|
||||
error={Boolean(form.errors.hiring_manager_id)}
|
||||
allowEmpty
|
||||
emptyLabel="Unassigned"
|
||||
/>
|
||||
<FieldError>{form.errors.hiring_manager_id}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Recruiter</label>
|
||||
|
|
@ -1034,12 +1158,15 @@ function JobOwnership({ job, canEdit }) {
|
|||
options={managersQuery.data ?? []}
|
||||
value={job.hiringManagerId || ''}
|
||||
onChange={(id) => {
|
||||
if (!id || id === String(job.hiringManagerId || '')) return
|
||||
patch.mutate({ hiring_manager_id: id })
|
||||
const next = id || null
|
||||
if (String(next || '') === String(job.hiringManagerId || '')) return
|
||||
patch.mutate({ hiring_manager_id: next })
|
||||
}}
|
||||
placeholder="Search hiring managers…"
|
||||
disabled={patch.isPending}
|
||||
loading={managersQuery.isPending}
|
||||
allowEmpty
|
||||
emptyLabel="Unassigned"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted text-sm">{job.hiringManager || '—'}</p>
|
||||
|
|
@ -1279,6 +1406,7 @@ 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">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,432 @@
|
|||
/* ============================================================
|
||||
Progress — per-job pipeline stage overview from GET /job/stats/fetch.
|
||||
|
||||
Two tabs: Overview (job picker + stage tiles) and All job posts (table).
|
||||
Counts are unique applicants by email; recruiter comes from
|
||||
job_posts.current_recruiter_id.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import DataTable from '../ui/DataTable'
|
||||
import { Badge, EmptyState, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as jobStatsApi from '../api/jobStats'
|
||||
|
||||
const STAGES = [
|
||||
{ key: 'shortlist', label: 'Shortlisted', icon: 'star', tone: 'blue' },
|
||||
{ key: 'screened', label: 'Screened', icon: 'eye', tone: 'purple' },
|
||||
{ key: 'assessment', label: 'Assessment', icon: 'check-square', tone: 'amber' },
|
||||
{ key: 'interviewed', label: 'Interviewed', icon: 'calendar', tone: 'indigo' },
|
||||
{ key: 'offered', label: 'Offered', icon: 'send', tone: 'teal' },
|
||||
{ key: 'onHold', label: 'On Hold', icon: 'clock', tone: 'amber' },
|
||||
{ key: 'rejected', label: 'Rejected', icon: 'x-circle', tone: 'red' },
|
||||
]
|
||||
|
||||
function sumField(jobs, key) {
|
||||
return jobs.reduce((total, job) => total + (Number(job[key]) || 0), 0)
|
||||
}
|
||||
|
||||
function StageTile({ stage, value, total }) {
|
||||
const pct = total ? Math.round((value / total) * 100) : 0
|
||||
return (
|
||||
<div className={`progress-stage stage-${stage.tone}`}>
|
||||
<div className="progress-stage-head">
|
||||
<span className="progress-stage-icon"><Icon name={stage.icon} /></span>
|
||||
<span className="progress-stage-label">{stage.label}</span>
|
||||
<span className="progress-stage-pct">{pct}%</span>
|
||||
</div>
|
||||
<strong className="progress-stage-value">{value}</strong>
|
||||
<div className="progress-stage-meter" aria-hidden="true">
|
||||
<i style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StageBar({ job }) {
|
||||
const used = STAGES.reduce((n, stage) => n + (job[stage.key] || 0), 0)
|
||||
const base = Math.max(used, job.total, 1)
|
||||
return (
|
||||
<div className="progress-bar-wrap">
|
||||
<div className="progress-bar-labels">
|
||||
<span>Current stage distribution</span>
|
||||
<span>{job.total} unique applicants</span>
|
||||
</div>
|
||||
<div className="progress-bar-track" role="img" aria-label="Stage distribution">
|
||||
{STAGES.map((stage) => {
|
||||
const n = job[stage.key] || 0
|
||||
if (!n) return null
|
||||
return (
|
||||
<div
|
||||
key={stage.key}
|
||||
className={`progress-bar-seg stage-${stage.tone}`}
|
||||
title={`${stage.label}: ${n}`}
|
||||
style={{ width: `${(n / base) * 100}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Progress() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const deepLinkJobId = searchParams.get('job') || ''
|
||||
|
||||
const [tab, setTab] = useState('overview')
|
||||
const [selectedId, setSelectedId] = useState(deepLinkJobId)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
|
||||
queryFn: async () => {
|
||||
const res = await jobStatsApi.list({ top: 500, skip: 0 })
|
||||
const rows = Array.isArray(res?.data) ? res.data : res?.data ? [res.data] : []
|
||||
return rows.map(jobStatsApi.toJobStatsView)
|
||||
},
|
||||
})
|
||||
|
||||
const jobs = statsQuery.data ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobs.length) {
|
||||
setSelectedId('')
|
||||
return
|
||||
}
|
||||
const matchId = (id) => jobs.some((j) => String(j.id) === String(id))
|
||||
if (deepLinkJobId && matchId(deepLinkJobId)) {
|
||||
setSelectedId(String(deepLinkJobId))
|
||||
setTab('overview')
|
||||
return
|
||||
}
|
||||
if (!selectedId || !matchId(selectedId)) {
|
||||
setSelectedId(String(jobs[0].id))
|
||||
}
|
||||
}, [jobs, selectedId, deepLinkJobId])
|
||||
|
||||
const selectJob = (id) => {
|
||||
const next = String(id || '')
|
||||
setSelectedId(next)
|
||||
setTab('overview')
|
||||
setSearchParams((prev) => {
|
||||
const nextParams = new URLSearchParams(prev)
|
||||
if (next) nextParams.set('job', next)
|
||||
else nextParams.delete('job')
|
||||
return nextParams
|
||||
}, { replace: true })
|
||||
}
|
||||
|
||||
const selected = jobs.find((j) => String(j.id) === String(selectedId)) || jobs[0] || null
|
||||
|
||||
const visibleJobs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return jobs
|
||||
return jobs.filter((job) => (
|
||||
job.title.toLowerCase().includes(q)
|
||||
|| (job.department || '').toLowerCase().includes(q)
|
||||
|| (job.location || '').toLowerCase().includes(q)
|
||||
|| (job.recruiterName || '').toLowerCase().includes(q)
|
||||
))
|
||||
}, [jobs, query])
|
||||
|
||||
const totalApplicants = sumField(jobs, 'total')
|
||||
const activePipeline = sumField(jobs, 'shortlist')
|
||||
+ sumField(jobs, 'screened')
|
||||
+ sumField(jobs, 'assessment')
|
||||
+ sumField(jobs, 'interviewed')
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Job post',
|
||||
sortable: true,
|
||||
render: (j) => (
|
||||
<div>
|
||||
<div className="cell-primary">{j.title}</div>
|
||||
<div className="cell-sub">
|
||||
{[j.department, j.location].filter(Boolean).join(' · ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'recruiterName',
|
||||
label: 'Recruiter',
|
||||
sortable: true,
|
||||
render: (j) => (
|
||||
j.recruiterName
|
||||
? <b>{j.recruiterName}</b>
|
||||
: <span className="text-muted">Unassigned</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
render: (j) => <Badge>{j.status}</Badge>,
|
||||
},
|
||||
{ key: 'total', label: 'Applicants', sortable: true, align: 'right', render: (j) => <b>{j.total}</b> },
|
||||
{ key: 'shortlist', label: 'Shortlisted', sortable: true, align: 'right' },
|
||||
{ key: 'screened', label: 'Screened', sortable: true, align: 'right' },
|
||||
{ key: 'interviewed', label: 'Interviewed', sortable: true, align: 'right' },
|
||||
{ key: 'offered', label: 'Offered', sortable: true, align: 'right' },
|
||||
{
|
||||
key: 'onHold',
|
||||
label: 'On hold',
|
||||
sortable: true,
|
||||
align: 'right',
|
||||
render: (j) => <span className="text-warning">{j.onHold}</span>,
|
||||
},
|
||||
{
|
||||
key: 'rejected',
|
||||
label: 'Rejected',
|
||||
sortable: true,
|
||||
align: 'right',
|
||||
render: (j) => <span className="text-danger">{j.rejected}</span>,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page progress-page">
|
||||
<PageHeader
|
||||
title="Progress"
|
||||
sub="Candidate progress across every job post, at a glance"
|
||||
/>
|
||||
|
||||
<div className="tabs" role="tablist" aria-label="Progress views">
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${tab === 'overview' ? 'active' : ''}`}
|
||||
onClick={() => setTab('overview')}
|
||||
>
|
||||
<Icon name="dashboard" /> Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${tab === 'jobs' ? 'active' : ''}`}
|
||||
onClick={() => setTab('jobs')}
|
||||
>
|
||||
<Icon name="briefcase" /> All job posts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{statsQuery.isPending && (
|
||||
<div className="card"><div className="card-body"><SkeletonRows rows={6} /></div></div>
|
||||
)}
|
||||
|
||||
{statsQuery.isError && (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load job progress">
|
||||
{friendlyAuthError(statsQuery.error, 'The server did not answer.')}
|
||||
{' '}This screen needs <code>jobs.view</code> or <code>pipeline.view</code>.
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)}
|
||||
|
||||
{!statsQuery.isPending && !statsQuery.isError && jobs.length === 0 && (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="briefcase" title="No job posts yet">
|
||||
Open a requisition to start tracking candidate progress.
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)}
|
||||
|
||||
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && tab === 'overview' && selected && (
|
||||
<>
|
||||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard
|
||||
icon="users"
|
||||
tone="i-indigo"
|
||||
label="Total applicants"
|
||||
value={totalApplicants}
|
||||
foot={`Across ${jobs.length} job post${jobs.length === 1 ? '' : 's'}`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon="pipeline"
|
||||
tone="i-purple"
|
||||
label="Active pipeline"
|
||||
value={activePipeline}
|
||||
foot="Currently progressing"
|
||||
/>
|
||||
<KpiCard
|
||||
icon="calendar"
|
||||
tone="i-blue"
|
||||
label="Interviewed"
|
||||
value={sumField(jobs, 'interviewed')}
|
||||
foot="Candidate interviews"
|
||||
/>
|
||||
<KpiCard
|
||||
icon="send"
|
||||
tone="i-green"
|
||||
label="Offers made"
|
||||
value={sumField(jobs, 'offered')}
|
||||
foot={`${sumField(jobs, 'hired')} candidates hired`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card mb-18">
|
||||
<div className="card-head progress-filter-head">
|
||||
<div>
|
||||
<h3>Pipeline at a glance</h3>
|
||||
<span className="ch-sub">Select a job post to inspect its current candidate distribution</span>
|
||||
</div>
|
||||
<select
|
||||
className="select"
|
||||
value={selectedId}
|
||||
onChange={(e) => selectJob(e.target.value)}
|
||||
aria-label="Select job post"
|
||||
>
|
||||
{jobs.map((job) => (
|
||||
<option key={job.id} value={String(job.id)}>{job.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="progress-selected">
|
||||
<div>
|
||||
{selected.department && (
|
||||
<span className="progress-eyebrow">{selected.department}</span>
|
||||
)}
|
||||
<h2>{selected.title}</h2>
|
||||
<p className="progress-meta">
|
||||
{selected.location && (
|
||||
<span><Icon name="map" /> {selected.location}</span>
|
||||
)}
|
||||
<span>
|
||||
Recruiter ·{' '}
|
||||
{selected.recruiterName
|
||||
? <b>{selected.recruiterName}</b>
|
||||
: <span className="text-muted">Unassigned</span>}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="progress-selected-total">
|
||||
<strong>{selected.total}</strong>
|
||||
<span>unique applicants</span>
|
||||
</div>
|
||||
<Badge>{selected.status}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="progress-stage-grid">
|
||||
{STAGES.map((stage) => (
|
||||
<StageTile
|
||||
key={stage.key}
|
||||
stage={stage}
|
||||
value={selected[stage.key] || 0}
|
||||
total={selected.total}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StageBar job={selected} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-2">
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Attention needed</h3>
|
||||
<span className="ch-sub">Where attention is needed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body progress-health">
|
||||
<div>
|
||||
<span className="progress-health-icon i-amber"><Icon name="clock" /></span>
|
||||
<div>
|
||||
<strong>{selected.onHold} candidates on hold</strong>
|
||||
<span>Review before the next hiring round</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="progress-health-icon i-red"><Icon name="x-circle" /></span>
|
||||
<div>
|
||||
<strong>{selected.rejected} rejected</strong>
|
||||
<span>
|
||||
{selected.total
|
||||
? `${Math.round((selected.rejected / selected.total) * 100)}% of total applicants`
|
||||
: 'No applicants yet'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Hiring outcome</h3>
|
||||
<span className="ch-sub">Selected job post</span>
|
||||
</div>
|
||||
<Badge>{selected.status}</Badge>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<div className="il">Offers</div>
|
||||
<div className="iv">{selected.offered}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="il">Hired</div>
|
||||
<div className="iv">{selected.hired}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="il">Interview rate</div>
|
||||
<div className="iv">
|
||||
{selected.total
|
||||
? `${Math.round((selected.interviewed / selected.total) * 100)}%`
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="il">Offer-to-hire</div>
|
||||
<div className="iv">
|
||||
{selected.offered
|
||||
? `${Math.round((selected.hired / selected.offered) * 100)}%`
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && tab === 'jobs' && (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>All job posts</h3>
|
||||
<span className="ch-sub">{visibleJobs.length} role{visibleJobs.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-search">
|
||||
<Icon name="search" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search jobs, teams, recruiters…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
rows={visibleJobs}
|
||||
pageSize={8}
|
||||
empty="No job posts match this search."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,660 @@
|
|||
/* ============================================================
|
||||
Requisitions — Annexure A employee requisitions on app.requisitions.
|
||||
|
||||
List is GET /forms/requisition/fetch (the JWT user; created_by is not sent).
|
||||
Create / amend write POST /forms/requisition/create and
|
||||
PATCH /forms/requisition/update?form_id=. Nested position / replacement_for /
|
||||
refferal_by match the paper form; layout reuses .hf-* from the candidate
|
||||
Forms tab so the hiring paperwork looks the same.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } 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 requisitionsApi from '../api/requisitions'
|
||||
import { EMPLOYMENT_TYPES, EMPLOYMENT_TYPE_LABEL, approvalStatus } from '../api/requisitions'
|
||||
import { fmtShort } from '../data/seed'
|
||||
|
||||
const TYPE_BADGE = {
|
||||
permanent: 'b-indigo',
|
||||
contract: 'b-teal',
|
||||
temporary: 'b-amber',
|
||||
internee: 'b-purple',
|
||||
}
|
||||
|
||||
const STATUS_BADGE = {
|
||||
approved: 'b-green',
|
||||
review: 'b-amber',
|
||||
open: 'b-indigo',
|
||||
}
|
||||
|
||||
function toDateInput(value) {
|
||||
if (!value) return ''
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? String(value).slice(0, 10) : d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function emptyToNull(value) {
|
||||
if (value === '' || value == null) return null
|
||||
return value
|
||||
}
|
||||
|
||||
function hasAny(obj, keys) {
|
||||
return keys.some((k) => obj?.[k])
|
||||
}
|
||||
|
||||
function approvalLabel(row) {
|
||||
return approvalStatus(row)
|
||||
}
|
||||
|
||||
async function fetchRequisitions() {
|
||||
const res = await requisitionsApi.list()
|
||||
return requisitionsApi.toRows(res)
|
||||
}
|
||||
|
||||
export default function Requisitions() {
|
||||
const { toast } = useToast()
|
||||
const { can } = useAuth()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const canCreate = can('requisitions.create')
|
||||
const canEdit = can('requisitions.edit')
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.requisitions.list(),
|
||||
queryFn: fetchRequisitions,
|
||||
})
|
||||
const rowsAll = listQuery.data ?? []
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [type, setType] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [editing, setEditing] = useState(null)
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const approved = rowsAll.filter((r) => approvalStatus(r).key === 'approved').length
|
||||
const review = rowsAll.filter((r) => approvalStatus(r).key === 'review').length
|
||||
return {
|
||||
total: rowsAll.length,
|
||||
open: rowsAll.filter((r) => approvalStatus(r).key === 'open').length,
|
||||
review,
|
||||
approved,
|
||||
}
|
||||
}, [rowsAll])
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
rowsAll.filter((r) => {
|
||||
const st = approvalStatus(r).key
|
||||
if (status && st !== status) return false
|
||||
const emp = r.position?.type || ''
|
||||
if (type && emp !== type) return false
|
||||
if (!q) return true
|
||||
const hay = [
|
||||
r.position?.title,
|
||||
r.position?.department,
|
||||
r.initiated_by,
|
||||
r.replacement_for?.to_replace,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return hay.includes(q.toLowerCase())
|
||||
}),
|
||||
[rowsAll, q, type, status],
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Position',
|
||||
sortable: true,
|
||||
sortValue: (r) => r.position?.title || '',
|
||||
render: (r) => (
|
||||
<>
|
||||
<div className="cell-primary">{r.position?.title || 'Untitled role'}</div>
|
||||
<div className="cell-sub">{r.position?.department || '—'}</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
sortable: true,
|
||||
sortValue: (r) => r.position?.type || '',
|
||||
render: (r) => {
|
||||
const t = r.position?.type
|
||||
if (!t) return <span className="text-muted">—</span>
|
||||
return <Badge className={TYPE_BADGE[t] || ''}>{EMPLOYMENT_TYPE_LABEL[t] || t}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'needed',
|
||||
label: 'Date needed',
|
||||
sortable: true,
|
||||
sortValue: (r) => r.position?.date_needed || '',
|
||||
render: (r) => (
|
||||
<span className="text-muted">
|
||||
{r.position?.date_needed ? fmtShort(new Date(r.position.date_needed)) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'initiated',
|
||||
label: 'Initiated by',
|
||||
sortable: true,
|
||||
sortValue: (r) => r.initiated_by || '',
|
||||
render: (r) => (
|
||||
<>
|
||||
<div className="cell-primary text-sm">{r.initiated_by || '—'}</div>
|
||||
<div className="cell-sub">{r.initiated_date ? fmtShort(new Date(r.initiated_date)) : ''}</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Approval',
|
||||
sortable: true,
|
||||
sortValue: (r) => approvalLabel(r).key,
|
||||
render: (r) => {
|
||||
const s = approvalLabel(r)
|
||||
return <Badge className={STATUS_BADGE[s.key]}>{s.label}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '_a',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (r) => (
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip={canEdit ? 'Edit' : 'View'}
|
||||
aria-label="Open requisition"
|
||||
onClick={() => setEditing(r)}
|
||||
>
|
||||
<Icon name={canEdit ? 'edit' : 'eye'} />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Requisitions"
|
||||
sub="Employee requisition forms — Annexure A"
|
||||
actions={
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!canCreate}
|
||||
title={!canCreate ? 'Requires requisitions.create' : undefined}
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
<Icon name="plus" /> New Requisition
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard label="Total" value={listQuery.isPending ? '—' : stats.total} icon="file" tone="i-indigo" />
|
||||
<KpiCard label="Open" value={listQuery.isPending ? '—' : stats.open} icon="clock" tone="i-amber" />
|
||||
<KpiCard label="In review" value={listQuery.isPending ? '—' : stats.review} icon="check-square" tone="i-teal" />
|
||||
<KpiCard label="Approved" value={listQuery.isPending ? '—' : stats.approved} icon="check-circle" tone="i-green" />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{listQuery.isPending && (
|
||||
<div className="card-body">
|
||||
<SkeletonRows rows={6} />
|
||||
</div>
|
||||
)}
|
||||
{listQuery.isError && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="file" title="Couldn’t load requisitions">
|
||||
{friendlyAuthError(listQuery.error, 'Request failed')}
|
||||
{' '}This screen needs the <code>requisitions.view</code> permission.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{!listQuery.isPending && !listQuery.isError && (
|
||||
<>
|
||||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-search">
|
||||
<Icon name="search" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search title, department, or initiator…"
|
||||
/>
|
||||
</div>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All approvals</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="review">In review</option>
|
||||
<option value="approved">Approved</option>
|
||||
</select>
|
||||
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="">All types</option>
|
||||
{EMPLOYMENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} rows={rows} pageSize={8} empty="No requisitions match these filters." />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<RequisitionEditor
|
||||
row={editing === 'new' ? null : editing}
|
||||
allowed={editing === 'new' ? canCreate : canEdit}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={async () => {
|
||||
await qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
setEditing(null)
|
||||
}}
|
||||
toast={toast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function blankForm() {
|
||||
return {
|
||||
department: '',
|
||||
title: '',
|
||||
date: toDateInput(new Date().toISOString()),
|
||||
date_needed: '',
|
||||
type: '',
|
||||
job_description: '',
|
||||
to_replace: '',
|
||||
grade: '',
|
||||
recruitment_title: '',
|
||||
date_separated: '',
|
||||
justification: '',
|
||||
budget: '',
|
||||
recommended_grade: '',
|
||||
employee_name: '',
|
||||
employee_department: '',
|
||||
initiated_by: '',
|
||||
initiated_date: '',
|
||||
recommended_by: '',
|
||||
recommended_date: '',
|
||||
approved_by_hr: false,
|
||||
approved_by_date_hr: '',
|
||||
approved_by_vp: false,
|
||||
approved_by_date_vp: '',
|
||||
approved_by_svp: false,
|
||||
approved_by_date_svp: '',
|
||||
is_replacement: false,
|
||||
is_referral: false,
|
||||
}
|
||||
}
|
||||
|
||||
function fromRow(row) {
|
||||
const pos = row.position || {}
|
||||
const rep = row.replacement_for || {}
|
||||
const ref = row.refferal_by || {}
|
||||
return {
|
||||
department: pos.department || '',
|
||||
title: pos.title || '',
|
||||
date: toDateInput(pos.date),
|
||||
date_needed: toDateInput(pos.date_needed),
|
||||
type: pos.type || '',
|
||||
job_description: pos.job_description || '',
|
||||
to_replace: rep.to_replace || '',
|
||||
grade: rep.grade || '',
|
||||
recruitment_title: rep.title || '',
|
||||
date_separated: toDateInput(rep.date_separated),
|
||||
justification: rep.justification || '',
|
||||
budget: rep.budget || '',
|
||||
recommended_grade: rep.recommended_grade || '',
|
||||
employee_name: ref.employee_name || '',
|
||||
employee_department: ref.employee_department || '',
|
||||
initiated_by: row.initiated_by || '',
|
||||
initiated_date: toDateInput(row.initiated_date),
|
||||
recommended_by: row.recommended_by || '',
|
||||
recommended_date: toDateInput(row.recommended_date),
|
||||
approved_by_hr: row.approved_by_hr === true,
|
||||
approved_by_date_hr: toDateInput(row.approved_by_date_hr),
|
||||
approved_by_vp: row.approved_by_vp === true,
|
||||
approved_by_date_vp: toDateInput(row.approved_by_date_vp),
|
||||
approved_by_svp: row.approved_by_svp === true,
|
||||
approved_by_date_svp: toDateInput(row.approved_by_date_svp),
|
||||
is_replacement: hasAny(rep, ['to_replace', 'grade', 'title', 'date_separated', 'justification', 'budget', 'recommended_grade']),
|
||||
is_referral: hasAny(ref, ['employee_name', 'employee_department']),
|
||||
}
|
||||
}
|
||||
|
||||
function toPayload(f) {
|
||||
const body = {
|
||||
position: {
|
||||
department: emptyToNull(f.department),
|
||||
title: emptyToNull(f.title),
|
||||
date: emptyToNull(f.date),
|
||||
date_needed: emptyToNull(f.date_needed),
|
||||
type: emptyToNull(f.type),
|
||||
job_description: emptyToNull(f.job_description),
|
||||
},
|
||||
initiated_by: emptyToNull(f.initiated_by),
|
||||
initiated_date: emptyToNull(f.initiated_date),
|
||||
recommended_by: emptyToNull(f.recommended_by),
|
||||
recommended_date: emptyToNull(f.recommended_date),
|
||||
approved_by_hr: f.approved_by_hr,
|
||||
approved_by_date_hr: emptyToNull(f.approved_by_date_hr),
|
||||
approved_by_vp: f.approved_by_vp,
|
||||
approved_by_date_vp: emptyToNull(f.approved_by_date_vp),
|
||||
approved_by_svp: f.approved_by_svp,
|
||||
approved_by_date_svp: emptyToNull(f.approved_by_date_svp),
|
||||
replacement_for: f.is_replacement
|
||||
? {
|
||||
to_replace: emptyToNull(f.to_replace),
|
||||
grade: emptyToNull(f.grade),
|
||||
title: emptyToNull(f.recruitment_title),
|
||||
date_separated: emptyToNull(f.date_separated),
|
||||
justification: emptyToNull(f.justification),
|
||||
budget: emptyToNull(f.budget),
|
||||
recommended_grade: emptyToNull(f.recommended_grade),
|
||||
}
|
||||
: {
|
||||
to_replace: null,
|
||||
grade: null,
|
||||
title: null,
|
||||
date_separated: null,
|
||||
justification: null,
|
||||
budget: null,
|
||||
recommended_grade: null,
|
||||
},
|
||||
refferal_by: f.is_referral
|
||||
? {
|
||||
employee_name: emptyToNull(f.employee_name),
|
||||
employee_department: emptyToNull(f.employee_department),
|
||||
}
|
||||
: {
|
||||
employee_name: null,
|
||||
employee_department: null,
|
||||
},
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
||||
const [fields, setFields] = useState(() => (row ? fromRow(row) : blankForm()))
|
||||
const [errors, setErrors] = useState({})
|
||||
const set = (k, v) => setFields((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body = toPayload(fields)
|
||||
if (row) return requisitionsApi.update(row.id, body)
|
||||
return requisitionsApi.create(body)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast(row ? 'Requisition updated' : 'Requisition saved', 'success')
|
||||
await onSaved()
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not save the requisition.'), 'error'),
|
||||
})
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault()
|
||||
const next = {}
|
||||
if (!fields.title.trim()) next.title = 'Enter the job title'
|
||||
setErrors(next)
|
||||
if (Object.keys(next).length) return
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="modal-lg"
|
||||
title={row ? 'Edit requisition' : 'New requisition'}
|
||||
subtitle="Annexure A — Employee Requisition Form"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" type="button" disabled={save.isPending} onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
{allowed && (
|
||||
<button className="btn btn-primary" form="requisition-form" type="submit" disabled={save.isPending}>
|
||||
{save.isPending ? 'Saving…' : row ? 'Save changes' : 'Save form'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="requisition-form" noValidate onSubmit={submit}>
|
||||
<fieldset disabled={!allowed} style={{ border: 0, margin: 0, padding: 0 }}>
|
||||
<div className="hf-block" style={{ marginTop: 0 }}>
|
||||
<div className="hf-block-title">Position request</div>
|
||||
<div className="hf-note">To: Human Resource Department</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>From (Dept.)</label>
|
||||
<input value={fields.department} onChange={(e) => set('department', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Job title <span className="req">*</span></label>
|
||||
<input
|
||||
className={errors.title ? 'err' : ''}
|
||||
value={fields.title}
|
||||
onChange={(e) => set('title', e.target.value)}
|
||||
/>
|
||||
<FieldError>{errors.title}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Date</label>
|
||||
<input type="date" value={fields.date} onChange={(e) => set('date', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Date needed</label>
|
||||
<input type="date" value={fields.date_needed} onChange={(e) => set('date_needed', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Permanent / Temporary / Contract / Internee</label>
|
||||
<select value={fields.type} onChange={(e) => set('type', e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{EMPLOYMENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Job description</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
style={{ minHeight: 72 }}
|
||||
value={fields.job_description}
|
||||
onChange={(e) => set('job_description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hf-block">
|
||||
<div className="hf-block-title">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.is_replacement}
|
||||
onChange={(e) => set('is_replacement', e.target.checked)}
|
||||
/>
|
||||
If a replacement, complete the following
|
||||
</label>
|
||||
</div>
|
||||
{fields.is_replacement && (
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Employee to be replaced</label>
|
||||
<input value={fields.to_replace} onChange={(e) => set('to_replace', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Grade</label>
|
||||
<input value={fields.grade} onChange={(e) => set('grade', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Job title (replaced employee)</label>
|
||||
<input
|
||||
value={fields.recruitment_title}
|
||||
onChange={(e) => set('recruitment_title', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Date separated</label>
|
||||
<input
|
||||
type="date"
|
||||
value={fields.date_separated}
|
||||
onChange={(e) => set('date_separated', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>In case of new/additional headcount, provide justification</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
style={{ minHeight: 56 }}
|
||||
value={fields.justification}
|
||||
onChange={(e) => set('justification', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Propose budget</label>
|
||||
<input value={fields.budget} onChange={(e) => set('budget', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Recommended grade</label>
|
||||
<input
|
||||
value={fields.recommended_grade}
|
||||
onChange={(e) => set('recommended_grade', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hf-block">
|
||||
<div className="hf-block-title">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.is_referral}
|
||||
onChange={(e) => set('is_referral', e.target.checked)}
|
||||
/>
|
||||
In case of internal recommendate
|
||||
</label>
|
||||
</div>
|
||||
{fields.is_referral && (
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Employee name</label>
|
||||
<input value={fields.employee_name} onChange={(e) => set('employee_name', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Employee department</label>
|
||||
<input
|
||||
value={fields.employee_department}
|
||||
onChange={(e) => set('employee_department', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hf-block">
|
||||
<div className="hf-block-title">Approvals</div>
|
||||
<div className="hf-sign-grid">
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Initiated by</span>
|
||||
<input
|
||||
placeholder="Name"
|
||||
value={fields.initiated_by}
|
||||
onChange={(e) => set('initiated_by', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={fields.initiated_date}
|
||||
onChange={(e) => set('initiated_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Recommended by · Director</span>
|
||||
<input
|
||||
placeholder="Name"
|
||||
value={fields.recommended_by}
|
||||
onChange={(e) => set('recommended_by', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={fields.recommended_date}
|
||||
onChange={(e) => set('recommended_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Approved by · Director HR</span>
|
||||
<label className="hf-note" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.approved_by_hr}
|
||||
onChange={(e) => set('approved_by_hr', e.target.checked)}
|
||||
/>
|
||||
{' '}Approved
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={fields.approved_by_date_hr}
|
||||
onChange={(e) => set('approved_by_date_hr', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Approved by · VP</span>
|
||||
<label className="hf-note" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.approved_by_vp}
|
||||
onChange={(e) => set('approved_by_vp', e.target.checked)}
|
||||
/>
|
||||
{' '}Approved
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={fields.approved_by_date_vp}
|
||||
onChange={(e) => set('approved_by_date_vp', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Approved by · SVP</span>
|
||||
<label className="hf-note" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.approved_by_svp}
|
||||
onChange={(e) => set('approved_by_svp', e.target.checked)}
|
||||
/>
|
||||
{' '}Approved
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={fields.approved_by_date_svp}
|
||||
onChange={(e) => set('approved_by_date_svp', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -271,7 +271,7 @@ function General({ registerSave }) {
|
|||
|
||||
/** Real data: GET /users/fetch (requires rbac_users.view). */
|
||||
function Users() {
|
||||
const { toast } = useToast()
|
||||
// const { toast } = useToast() // used by Invite User button above
|
||||
const [editing, setEditing] = useState(null)
|
||||
const usersQuery = useQuery({
|
||||
queryKey: qk.users.list(),
|
||||
|
|
@ -289,9 +289,11 @@ function Users() {
|
|||
{usersQuery.isPending ? 'Loading…' : `${users.length} users`}
|
||||
</span>
|
||||
</div>
|
||||
{/* Hidden: Invite User — restore this block to show the button again.
|
||||
<button className="btn btn-primary btn-sm" onClick={() => toast('Invite sent', 'success')}>
|
||||
<Icon name="plus" /> Invite User
|
||||
</button>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{usersQuery.isError ? (
|
||||
|
|
|
|||
|
|
@ -1782,12 +1782,120 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
/* The four-form switcher must wrap rather than overflow on narrow screens. */
|
||||
.cand-page .seg { flex-wrap: wrap; }
|
||||
|
||||
/* Rating-table scale header: full words down to 640px, bare numbers below. */
|
||||
/* Rating-table scale header: full words down to 640px, 25%/50%/75%/100% below. */
|
||||
.hf-scale-short { display: none; }
|
||||
|
||||
/* Empty-state CTA on the hiring forms: full-size, and full-width on phones. */
|
||||
.hf-cta { padding: 11px 26px; font-size: 14.5px; }
|
||||
|
||||
/* ================= PROGRESS (job-post stage overview) ================= */
|
||||
.progress-filter-head { flex-wrap: wrap; }
|
||||
.progress-filter-head .select { min-width: 240px; }
|
||||
.progress-selected {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
||||
flex-wrap: wrap; margin-bottom: 18px;
|
||||
}
|
||||
.progress-eyebrow {
|
||||
display: inline-block; font-size: var(--fs-xs); font-weight: 600; letter-spacing: .04em;
|
||||
text-transform: uppercase; color: var(--primary); margin-bottom: 4px;
|
||||
}
|
||||
.progress-selected h2 {
|
||||
font-family: var(--font-display); font-size: var(--fs-xl); font-weight: 600;
|
||||
letter-spacing: -.02em; margin: 0 0 6px;
|
||||
}
|
||||
.progress-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 14px; align-items: center;
|
||||
color: var(--text-2); font-size: var(--fs-sm); margin: 0;
|
||||
}
|
||||
.progress-meta svg { width: 14px; height: 14px; vertical-align: -2px; margin-right: 4px; }
|
||||
.progress-selected-total {
|
||||
display: flex; flex-direction: column; align-items: center; text-align: center;
|
||||
min-width: 110px;
|
||||
}
|
||||
.progress-selected-total strong {
|
||||
display: block; font-family: var(--font-display); font-size: 32px; font-weight: 600;
|
||||
letter-spacing: -.02em; line-height: 1;
|
||||
}
|
||||
.progress-selected-total span {
|
||||
display: block; font-size: var(--fs-xs); color: var(--text-3); margin-top: 4px;
|
||||
}
|
||||
|
||||
.progress-stage-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px; margin-bottom: 18px;
|
||||
}
|
||||
.progress-stage {
|
||||
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
}
|
||||
.progress-stage-head {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.progress-stage-icon {
|
||||
width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; flex: 0 0 auto;
|
||||
}
|
||||
.progress-stage-icon svg { width: 13px; height: 13px; }
|
||||
.progress-stage-label { font-size: var(--fs-sm); color: var(--text-2); font-weight: 500; min-width: 0; }
|
||||
.progress-stage-pct { margin-left: auto; font-size: var(--fs-xs); color: var(--text-3); font-weight: 600; }
|
||||
.progress-stage-value {
|
||||
display: block; font-family: var(--font-display); font-size: 24px; font-weight: 600;
|
||||
letter-spacing: -.02em; line-height: 1.1; margin-bottom: 10px;
|
||||
}
|
||||
.progress-stage-meter {
|
||||
height: 4px; border-radius: 99px; background: var(--bg-sunken); overflow: hidden;
|
||||
}
|
||||
.progress-stage-meter > i { display: block; height: 100%; border-radius: 99px; }
|
||||
|
||||
.progress-stage.stage-blue .progress-stage-icon { background: var(--info-soft); color: var(--info); }
|
||||
.progress-stage.stage-blue .progress-stage-meter > i { background: var(--info); }
|
||||
.progress-stage.stage-purple .progress-stage-icon { background: var(--purple-soft); color: var(--purple); }
|
||||
.progress-stage.stage-purple .progress-stage-meter > i { background: var(--purple); }
|
||||
.progress-stage.stage-amber .progress-stage-icon { background: var(--warning-soft); color: var(--warning); }
|
||||
.progress-stage.stage-amber .progress-stage-meter > i { background: var(--warning); }
|
||||
.progress-stage.stage-indigo .progress-stage-icon { background: var(--primary-soft); color: var(--primary); }
|
||||
.progress-stage.stage-indigo .progress-stage-meter > i { background: var(--primary); }
|
||||
.progress-stage.stage-teal .progress-stage-icon { background: var(--teal-soft); color: var(--teal); }
|
||||
.progress-stage.stage-teal .progress-stage-meter > i { background: var(--teal); }
|
||||
.progress-stage.stage-red .progress-stage-icon { background: var(--danger-soft); color: var(--danger); }
|
||||
.progress-stage.stage-red .progress-stage-meter > i { background: var(--danger); }
|
||||
|
||||
.progress-bar-wrap { margin-top: 4px; }
|
||||
.progress-bar-labels {
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
font-size: var(--fs-sm); color: var(--text-2); margin-bottom: 8px;
|
||||
}
|
||||
.progress-bar-labels span:last-child { color: var(--text-3); }
|
||||
.progress-bar-track {
|
||||
display: flex; gap: 3px; height: 8px; border-radius: 99px; overflow: hidden;
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
.progress-bar-seg { min-width: 3px; height: 100%; border-radius: 99px; }
|
||||
.progress-bar-seg.stage-blue { background: var(--info); }
|
||||
.progress-bar-seg.stage-purple { background: var(--purple); }
|
||||
.progress-bar-seg.stage-amber { background: var(--warning); }
|
||||
.progress-bar-seg.stage-indigo { background: var(--primary); }
|
||||
.progress-bar-seg.stage-teal { background: var(--teal); }
|
||||
.progress-bar-seg.stage-red { background: var(--danger); }
|
||||
|
||||
.progress-health { display: flex; flex-direction: column; gap: 16px; }
|
||||
.progress-health > div { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.progress-health-icon {
|
||||
width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; flex: 0 0 auto;
|
||||
}
|
||||
.progress-health-icon svg { width: 18px; height: 18px; }
|
||||
.progress-health strong { display: block; font-size: var(--fs-base); margin-bottom: 2px; }
|
||||
.progress-health span { font-size: var(--fs-sm); color: var(--text-3); }
|
||||
.text-warning { color: var(--warning); }
|
||||
.text-danger { color: var(--danger); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.progress-selected-total { align-items: flex-start; text-align: left; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.progress-stage-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.progress-filter-head .select { width: 100%; min-width: 0; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
≤640 / ≤400 — consolidated phone rules for the late bolt-on sections
|
||||
(Dashboard v2 grid, candidate page, hiring forms). Kept in ONE block
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default defineConfig({
|
|||
// VITE_API_TARGET repoints the proxy when the API runs elsewhere
|
||||
// (e.g. 8001 locally because another service holds 8000).
|
||||
proxy: {
|
||||
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3)(/|$)': {
|
||||
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3|forms|requisitions)(/|$)': {
|
||||
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
// Several API prefixes double as SPA routes (/jobs, /inbox, …).
|
||||
|
|
|
|||
Loading…
Reference in New Issue