pull/45/head
ahmed.mujtaba 2026-09-01 18:25:53 +05:00
parent cf40e43423
commit 9b5c2c8053
29 changed files with 1675 additions and 413 deletions

View File

@ -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(

View File

@ -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]

View File

@ -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()

View File

@ -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,
}

View File

@ -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]

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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:

View File

@ -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));

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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 $$;

View File

@ -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()

View File

@ -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"

View File

@ -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;

View File

@ -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')),

View File

@ -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' }
}

View File

@ -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' },

View File

@ -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 = [

View File

@ -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],

View File

@ -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). */

View File

@ -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,

View File

@ -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>

View File

@ -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="Couldnt 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>
)
}

View File

@ -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 ? (

View File

@ -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, …).