pull/45/head
parent
cf40e43423
commit
9b5c2c8053
|
|
@ -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,80 @@ 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),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Searchable picker for job create: `{position_title} - {department}`.
|
||||
|
||||
`q` matches either field (ilike). Empty `q` returns recent rows.
|
||||
"""
|
||||
try:
|
||||
service = RequisitionForm(session=session)
|
||||
data = await service.search(q, top=top)
|
||||
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),
|
||||
):
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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,214 @@
|
|||
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):
|
||||
"""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.
|
||||
"""
|
||||
statement = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||
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 +300,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 +323,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()
|
||||
|
|
|
|||
|
|
@ -30,3 +30,65 @@ def serialize_form(
|
|||
"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,16 +5,17 @@ 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_READY_STATUSES,
|
||||
FORM_TYPES,
|
||||
RECOMMENDATIONS,
|
||||
combined_summary,
|
||||
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.history.enums import HistoryEvent
|
||||
|
|
@ -96,39 +97,6 @@ class CandidateForm:
|
|||
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]
|
||||
|
|
@ -251,10 +219,6 @@ class CandidateForm:
|
|||
)
|
||||
inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload)
|
||||
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 +233,26 @@ 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()
|
||||
|
||||
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": payload.get("sections"),
|
||||
"fields": payload.get("fields"),
|
||||
"recommendation": payload.get("recommendation"),
|
||||
"created_by": _user_id(current_user),
|
||||
},
|
||||
)
|
||||
await HistoryRecorder(self.session).record(
|
||||
HistoryEvent.FORM_CREATED,
|
||||
current_user=current_user,
|
||||
|
|
@ -309,7 +271,17 @@ class CandidateForm:
|
|||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
|
||||
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:
|
||||
fields["sections"] = payload.get("sections")
|
||||
if "fields" in payload:
|
||||
fields["fields"] = payload.get("fields")
|
||||
if "recommendation" in payload:
|
||||
fields["recommendation"] = payload.get("recommendation")
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
|
|
@ -334,3 +306,39 @@ class CandidateForm:
|
|||
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):
|
||||
created_by = _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):
|
||||
rows = await Requisition.search(self.session, q, top=top)
|
||||
return [serialize_requisition_option(r) for r in rows]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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 +23,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]"},
|
||||
|
|
@ -61,9 +57,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 +89,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(
|
||||
|
|
@ -530,4 +550,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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -70,6 +71,7 @@ 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,
|
||||
"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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -288,11 +316,23 @@ 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")
|
||||
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 +345,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:
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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|requisitions)(/|$) {
|
||||
proxy_pass http://backend-api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
@ -29,6 +29,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')),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
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. */
|
||||
export function search({ q, top } = {}) {
|
||||
return request('/forms/requisition/search', {
|
||||
params: { q: q || undefined, top },
|
||||
})
|
||||
}
|
||||
|
||||
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,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
|
||||
|
|
@ -34,6 +34,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 = [
|
||||
|
|
|
|||
|
|
@ -124,6 +124,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 = '') => ['requisitions', 'search', q],
|
||||
},
|
||||
interviews: {
|
||||
all: () => ['interviews'],
|
||||
range: (p = {}) => ['interviews', 'range', p],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -62,7 +63,7 @@ function useFormsWrite({ userId, mutationFn, success, onDone }) {
|
|||
export default function CandidateFormsTab({ userId, live }) {
|
||||
const { can } = useAuth()
|
||||
// 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
|
||||
|
|
@ -107,7 +108,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,6 +125,13 @@ 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.
|
||||
|
|
@ -132,13 +140,11 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
: { manual_upload_candidate_id: manualId }
|
||||
|
||||
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' },
|
||||
|
|
@ -165,21 +171,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}
|
||||
|
|
@ -570,270 +561,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). */
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -422,6 +423,7 @@ function SearchSelect({
|
|||
allowEmpty = false,
|
||||
emptyLabel = 'Unassigned',
|
||||
error = false,
|
||||
onQueryChange,
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -436,12 +438,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%' }}>
|
||||
|
|
@ -510,9 +519,43 @@ function useRecruiterDirectory() {
|
|||
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
const [reqQ, setReqQ] = useState('')
|
||||
const [debouncedReqQ, setDebouncedReqQ] = useState('')
|
||||
const [pickedReq, setPickedReq] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedReqQ(reqQ.trim()), 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [reqQ])
|
||||
|
||||
const requisitionsQuery = useQuery({
|
||||
queryKey: qk.requisitions.search(debouncedReqQ),
|
||||
queryFn: async () => {
|
||||
const res = await requisitionsApi.search({ q: debouncedReqQ })
|
||||
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])
|
||||
|
||||
const form = useFormState({
|
||||
hiring_manager_id: '',
|
||||
current_recruiter_id: '',
|
||||
requisition_id: '',
|
||||
title: '',
|
||||
department: '',
|
||||
location: '',
|
||||
|
|
@ -568,7 +611,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 +641,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 +695,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 +735,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 +743,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>
|
||||
)}
|
||||
|
|
@ -885,7 +958,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,7 +969,7 @@ 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -928,7 +1000,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 +1008,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 +1106,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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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