Merge pull request 'Add_ATS_Order_by' (#45) from Add_ATS_Order_by into main
Deploy to S3 / deploy (push) Successful in 32s Details

Reviewed-on: #45
pull/46/head
ahmed.mujtaba 2026-09-02 08:49:25 +00:00
commit 6db8e7aff3
49 changed files with 3420 additions and 730 deletions

5
.gitignore vendored
View File

@ -82,4 +82,7 @@ docker.local.frontend/dist/** */
frontend/dist/index.html frontend/dist/index.html
frontend/dist/index.html frontend/dist/index.html
tests/** tests/**
/backend/tests/** /backend/tests/**
frontend/dist/**
nginx.conf
smoke.test.mjs

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 import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
import uuid
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from candidate_forms.plugins import definitions_payload from candidate_forms.plugins import definitions_payload
from candidate_forms.views import CandidateForm from candidate_forms.views import CandidateForm
from db_setup import get_session from db_setup import get_session
from users.permissions import PermissionTag, require_permission from users.permissions import PermissionTag, require_permission
from candidate_forms.enums import EmploymentType, Position, ReplacementFor, InternalRecommendate
from candidate_forms.views import RequisitionForm
router = APIRouter() 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): class FormCreate(BaseModel):
form_type: str form_type: str
@ -32,6 +68,85 @@ class FormUpdate(BaseModel):
fields: dict | None = None fields: dict | None = None
recommendation: str | None = None recommendation: str | None = None
@router.get("/forms/requisition/search")
async def search_requisitions(
current_user: dict = Depends(
require_permission(
PermissionTag.REQUISITIONS_VIEW,
PermissionTag.JOB_BOARD_CREATE,
PermissionTag.JOBS_CREATE,
require_all=False,
)
),
q: str | None = Query(None),
top: int = Query(50, ge=1, le=100),
job_post_id: uuid.UUID | None = Query(None),
session: AsyncSession = Depends(get_session),
):
"""Searchable picker for job create: `{position_title} - {department}`.
`q` matches either field (ilike). Empty `q` returns recent rows.
Linked requisitions are omitted (1:1 with job posts). Pass `job_post_id`
on edit so the job's current requisition remains selectable until unlinked.
"""
try:
service = RequisitionForm(session=session)
data = await service.search(q, top=top, job_post_id=job_post_id)
return JSONResponse(content={"data": data, "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/forms/requisition/fetch")
async def fetch_requisition_form(
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
form_id:str=Query(None),
session:AsyncSession=Depends(get_session),
):
"""Requisition table. Admins get every non-deleted row (job link does not
hide anything). Other roles stay scoped to created_by."""
try:
service=RequisitionForm(session=session)
data=await service.get_form_by_id(form_id,current_user)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/forms/requisition/create")
async def create_requisition_form(
payload: RequisitionFormCreate,
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_CREATE)),
session:AsyncSession=Depends(get_session),
):
try:
service = RequisitionForm(session=session)
data = await service.create_form(payload.model_dump(exclude_unset=True), current_user)
return JSONResponse(content={"data": data, "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/forms/requisition/update")
async def update_requisition_form(
payload: RequisitionFormUpdate,
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_EDIT)),
form_id:str=Query(...),
session:AsyncSession=Depends(get_session),
):
try:
service=RequisitionForm(session=session)
data=await service.update_form(form_id,payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/forms/definitions") @router.get("/forms/definitions")
async def fetch_form_definitions( async def fetch_form_definitions(
@ -61,6 +176,7 @@ async def fetch_forms(
service = CandidateForm(session=session) service = CandidateForm(session=session)
data, summary, total = await service.get_forms( data, summary, total = await service.get_forms(
form_id, inbox_id, manual_upload_candidate_id, job_post_id, form_type, top, skip, form_id, inbox_id, manual_upload_candidate_id, job_post_id, form_type, top, skip,
current_user=current_user,
) )
return JSONResponse( return JSONResponse(
content={"data": data, "summary": summary, "total": total, "status_code": 200} content={"data": data, "summary": summary, "total": total, "status_code": 200}

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,235 @@
import uuid 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 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: def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
class Requisition(SQLModel, table=True):
__tablename__ = "requisitions"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
department: Optional[str] = None
position_title: Optional[str] = None
date: Optional[Date] = None
date_needed: Optional[Date] = None
employment_type: Optional[EmploymentType] = Field(
default=None,
sa_type=SAEnum(
EmploymentType,
name="employmenttype",
schema="app",
native_enum=True,
values_callable=lambda enum: [member.value for member in enum],
),
)
job_description: Optional[str] = None
employee_name: Optional[str] = None
employee_department: Optional[str] = None
to_replace: Optional[str] = None
grade: Optional[str] = None
recruitment_title: Optional[str] = None
date_separated: Optional[Date] = None
justification: Optional[str] = None
budget: Optional[str] = None
recommended_grade: Optional[str] = None
initiated_by: Optional[str] = None
initiated_date: Optional[Date] = None
recommended_by: Optional[str] = None
recommended_date: Optional[Date] = None
approved_by_hr: Optional[bool] = None
approved_by_date_hr: Optional[Date] = None
approved_by_vp: Optional[bool] = None
approved_by_date_vp: Optional[Date] = None
approved_by_svp: Optional[bool] = None
approved_by_date_svp: Optional[Date] = None
created_by: Optional[uuid.UUID] = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_deleted: bool = Field(default=False)
# Optional 1:1: job_posts.requisition_id points here. uselist=False so a
# requisition has at most one job post (enforced in DB by the unique FK).
job_post: Optional["JobPosts"] = Relationship(
back_populates="requisition",
sa_relationship_kwargs={"uselist": False, "lazy": "selectin"},
)
@classmethod
async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=None):
qry = select(cls).where(cls.is_deleted == False) # noqa: E712
if created_by is not None:
qry = qry.where(cls.created_by == created_by)
if record_id not in (None, ""):
try:
uid = uuid.UUID(str(record_id))
except (TypeError, ValueError):
return None
qry = qry.where(cls.id == uid)
qry = qry.order_by(cls.created_at.desc(),cls.id.desc())
result = await session.execute(qry)
return result.scalars().first()
result = await session.execute(qry.order_by(cls.created_at.desc(),cls.id.desc()))
return list(result.scalars().all())
@classmethod
async def search(
cls,
session: AsyncSession,
q: str | None = None,
*,
top: int = 50,
job_post_id=None,
):
"""Dropdown rows: match position_title or department (either side).
Empty `q` returns the most recent non-deleted rows so the picker has a
list before the user types. Not scoped to created_by job creators
need the org-wide list, not only requisitions they opened themselves.
job_posts.requisition_id is 1:1. Hide requisitions already linked to a
live job post. Pass `job_post_id` when editing so that job's current
requisition stays in the list until the link is cleared.
"""
from job.job_post.models import JobPosts
statement = select(cls).where(cls.is_deleted == False) # noqa: E712
held = select(JobPosts.requisition_id).where(
JobPosts.requisition_id.is_not(None),
JobPosts.is_deleted == False, # noqa: E712
)
except_uid = JobPosts._as_uuid(job_post_id) if job_post_id else None
if except_uid is not None:
held = held.where(JobPosts.id != except_uid)
statement = statement.where(cls.id.notin_(held))
term = (q or "").strip()
if term:
like = f"%{term}%"
statement = statement.where(
or_(cls.position_title.ilike(like), cls.department.ilike(like))
)
limit = max(1, min(int(top or 50), 100))
statement = statement.order_by(cls.created_at.desc(), cls.id.desc()).limit(limit)
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def insert_form(cls, session: AsyncSession, fields: dict):
position = fields.get("position") if fields.get("position") else {}
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
row = cls(
department=position.get("department") if position.get("department") else None,
position_title=position.get("title") if position.get("title") else None,
date=position.get("date") if position.get("date") else None,
date_needed=position.get("date_needed") if position.get("date_needed") else None,
employment_type=EmploymentType(position.get("type")) if position.get("type") else None,
job_description=position.get("job_description") if position.get("job_description") else None,
employee_name=referral.get("employee_name") if referral.get("employee_name") else None,
employee_department=referral.get("employee_department") if referral.get("employee_department") else None,
to_replace=replacement.get("to_replace") if replacement.get("to_replace") else None,
grade=replacement.get("grade") if replacement.get("grade") else None,
recruitment_title=replacement.get("title") if replacement.get("title") else None,
date_separated=replacement.get("date_separated") if replacement.get("date_separated") else None,
justification=replacement.get("justification") if replacement.get("justification") else None,
budget=replacement.get("budget") if replacement.get("budget") else None,
recommended_grade=replacement.get("recommended_grade") if replacement.get("recommended_grade") else None,
initiated_by=fields.get("initiated_by") if fields.get("initiated_by") else None,
initiated_date=fields.get("initiated_date") if fields.get("initiated_date") else None,
recommended_by=fields.get("recommended_by") if fields.get("recommended_by") else None,
recommended_date=fields.get("recommended_date") if fields.get("recommended_date") else None,
approved_by_hr=fields.get("approved_by_hr") if fields.get("approved_by_hr") is not None else None,
approved_by_date_hr=fields.get("approved_by_date_hr") if fields.get("approved_by_date_hr") else None,
approved_by_vp=fields.get("approved_by_vp") if fields.get("approved_by_vp") is not None else None,
approved_by_date_vp=fields.get("approved_by_date_vp") if fields.get("approved_by_date_vp") else None,
approved_by_svp=fields.get("approved_by_svp") if fields.get("approved_by_svp") is not None else None,
approved_by_date_svp=fields.get("approved_by_date_svp") if fields.get("approved_by_date_svp") else None,
created_by=fields.get("created_by") if fields.get("created_by") else None,
)
session.add(row)
await session.commit()
return await cls.get_form_by_id(session, row.id)
@classmethod
async def update_form(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_form_by_id(session, record_id)
if not row:
return None
if "position" in fields:
position = fields.get("position") if fields.get("position") else {}
if "department" in position:
row.department = position.get("department") if position.get("department") else None
if "title" in position:
row.position_title = position.get("title") if position.get("title") else None
if "date" in position:
row.date = position.get("date") if position.get("date") else None
if "date_needed" in position:
row.date_needed = position.get("date_needed") if position.get("date_needed") else None
if "type" in position:
row.employment_type = EmploymentType(position.get("type")) if position.get("type") else None
if "job_description" in position:
row.job_description = position.get("job_description") if position.get("job_description") else None
if "replacement_for" in fields:
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
if "to_replace" in replacement:
row.to_replace = replacement.get("to_replace") if replacement.get("to_replace") else None
if "grade" in replacement:
row.grade = replacement.get("grade") if replacement.get("grade") else None
if "title" in replacement:
row.recruitment_title = replacement.get("title") if replacement.get("title") else None
if "date_separated" in replacement:
row.date_separated = replacement.get("date_separated") if replacement.get("date_separated") else None
if "justification" in replacement:
row.justification = replacement.get("justification") if replacement.get("justification") else None
if "budget" in replacement:
row.budget = replacement.get("budget") if replacement.get("budget") else None
if "recommended_grade" in replacement:
row.recommended_grade = replacement.get("recommended_grade") if replacement.get("recommended_grade") else None
if "refferal_by" in fields:
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
if "employee_name" in referral:
row.employee_name = referral.get("employee_name") if referral.get("employee_name") else None
if "employee_department" in referral:
row.employee_department = referral.get("employee_department") if referral.get("employee_department") else None
if "initiated_by" in fields:
row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None
if "initiated_date" in fields:
row.initiated_date = fields.get("initiated_date") if fields.get("initiated_date") else None
if "recommended_by" in fields:
row.recommended_by = fields.get("recommended_by") if fields.get("recommended_by") else None
if "recommended_date" in fields:
row.recommended_date = fields.get("recommended_date") if fields.get("recommended_date") else None
if "approved_by_hr" in fields:
row.approved_by_hr = fields.get("approved_by_hr") if fields.get("approved_by_hr") is not None else None
if "approved_by_date_hr" in fields:
row.approved_by_date_hr = fields.get("approved_by_date_hr") if fields.get("approved_by_date_hr") else None
if "approved_by_vp" in fields:
row.approved_by_vp = fields.get("approved_by_vp") if fields.get("approved_by_vp") is not None else None
if "approved_by_date_vp" in fields:
row.approved_by_date_vp = fields.get("approved_by_date_vp") if fields.get("approved_by_date_vp") else None
if "approved_by_svp" in fields:
row.approved_by_svp = fields.get("approved_by_svp") if fields.get("approved_by_svp") is not None else None
if "approved_by_date_svp" in fields:
row.approved_by_date_svp = fields.get("approved_by_date_svp") if fields.get("approved_by_date_svp") else None
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
class CandidateForms(SQLModel, table=True): class CandidateForms(SQLModel, table=True):
"""One digitized hiring form (Annexure A requisition, or one of the two """One digitized hiring form (Annexure A requisition, or one of the two
@ -100,8 +321,20 @@ class CandidateForms(SQLModel, table=True):
return list(result.scalars().all()), total return list(result.scalars().all()), total
@classmethod @classmethod
async def insert_form(cls, session: AsyncSession, fields: dict): async def insert_form(cls, session: AsyncSession, fields: dict):
row = cls(**fields) 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) session.add(row)
await session.commit() await session.commit()
return await cls.get_form_by_id(session, row.id) return await cls.get_form_by_id(session, row.id)
@ -111,8 +344,18 @@ class CandidateForms(SQLModel, table=True):
row = await cls.get_form_by_id(session, record_id) row = await cls.get_form_by_id(session, record_id)
if not row: if not row:
return None return None
for key, value in fields.items(): if "interviewer_id" in fields:
setattr(row, key, value) 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() row.updated_at = _now()
session.add(row) session.add(row)
await session.commit() await session.commit()

View File

@ -9,16 +9,17 @@ into every saved row so historical records survive future renames.
FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit") FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit")
RATING_MIN = 1 RATING_POINTS = (25, 50, 75, 100)
RATING_MAX = 4 # Paper ticks used to be 14; coerce those to the matching percentage.
_LEGACY_TICK = {1: 25, 2: 50, 3: 75, 4: 100}
RATING_LABELS = { RATING_LABELS = {
1: "Below Average (1)", 25: "Below Average (25%)",
2: "Average (2)", 50: "Average (50%)",
3: "Good (3)", 75: "Good (75%)",
4: "Excellent (4)", 100: "Excellent (100%)",
} }
RATING_SCALE_NOTE = ( RATING_SCALE_NOTE = (
"Rating Scale: 1 = Below Average | 2 = Average | 3 = Good | 4 = Excellent. " "Rating Scale: Below Average = 25% | Average = 50% | Good = 75% | Excellent = 100%. "
"Tick the box that applies for each criterion." "Tick the box that applies for each criterion."
) )
@ -194,6 +195,7 @@ def definitions_payload() -> dict:
"form_types": list(FORM_TYPES), "form_types": list(FORM_TYPES),
"forms": FORM_DEFINITIONS, "forms": FORM_DEFINITIONS,
"rating_labels": {str(k): v for k, v in RATING_LABELS.items()}, "rating_labels": {str(k): v for k, v in RATING_LABELS.items()},
"rating_points": list(RATING_POINTS),
"recommendations": list(RECOMMENDATIONS), "recommendations": list(RECOMMENDATIONS),
"recommendation_labels": dict(RECOMMENDATION_LABELS), "recommendation_labels": dict(RECOMMENDATION_LABELS),
"employment_types": list(EMPLOYMENT_TYPES), "employment_types": list(EMPLOYMENT_TYPES),
@ -211,9 +213,10 @@ def _coerce_rating(value):
raise ValueError(f"rating must be a number, got {value!r}") raise ValueError(f"rating must be a number, got {value!r}")
if number != int(number): if number != int(number):
raise ValueError(f"rating must be a whole number, got {value!r}") raise ValueError(f"rating must be a whole number, got {value!r}")
rating = int(number) rating = _LEGACY_TICK.get(int(number), int(number))
if rating < RATING_MIN or rating > RATING_MAX: if rating not in RATING_POINTS:
raise ValueError(f"rating must be between {RATING_MIN} and {RATING_MAX}, got {rating}") allowed = ", ".join(str(p) for p in RATING_POINTS)
raise ValueError(f"rating must be one of {allowed}, got {rating}")
return rating return rating
@ -224,15 +227,34 @@ def _mean(values, digits=2):
return round(sum(values) / len(values), digits) return round(sum(values) / len(values), digits)
def to_percent(score):
"""Keep derived scores on 0100.
New ticks are 25/50/75/100 and averages are already percentages. Legacy
14 ticks or means (0, 4] convert once via (score / 4) × 100.
"""
if score is None:
return None
try:
number = float(score)
except (TypeError, ValueError):
return None
if 0 < number <= 4:
return round((number / 4) * 100, 2)
return round(number, 2)
def normalize_sections(form_type: str, sections): def normalize_sections(form_type: str, sections):
"""Validate submitted rated sections against the form definition and """Validate submitted rated sections against the form definition and
recompute all derived numbers. Returns (normalized_sections, overall_score). recompute all derived numbers. Returns (normalized_sections, overall_score).
Every definition section is emitted in definition order with denormalized Every definition section is emitted in definition order with denormalized
labels; submitted per-criterion ratings are merged in; client-sent averages labels; submitted per-criterion ratings are merged in; client-sent averages
are discarded and recomputed (mean of the non-null ratings, 2 dp). The are discarded and recomputed. Criterion ticks are 25/50/75/100. A section
overall score is the mean of the section averages. Raises ValueError on average is the mean of those percentages; the overall score is the mean of
unknown section/criterion keys or out-of-range ratings (422 material). the section averages. Legacy 14 ticks are coerced to the matching percent
before averaging. Raises ValueError on unknown section/criterion keys or
ratings outside the scale (422 material).
""" """
definition = FORM_DEFINITIONS.get(form_type) definition = FORM_DEFINITIONS.get(form_type)
if definition is None: if definition is None:
@ -278,7 +300,7 @@ def normalize_sections(form_type: str, sections):
} }
for c in section_def["criteria"] for c in section_def["criteria"]
] ]
average = _mean([c["rating"] for c in criteria]) average = to_percent(_mean([c["rating"] for c in criteria]))
if average is not None: if average is not None:
section_averages.append(average) section_averages.append(average)
normalized.append( normalized.append(
@ -333,8 +355,10 @@ def combined_summary(rows):
`rows` are candidate_forms records (attribute access: form_type, created_at, `rows` are candidate_forms records (attribute access: form_type, created_at,
sections). The latest interview_analysis row supplies the technical and sections). The latest interview_analysis row supplies the technical and
behavioral averages, the latest cultural_fit row the cultural average. behavioral averages, the latest cultural_fit row the cultural average.
The combined overall (mean of the three section averages, 2 dp) appears The combined overall (mean of the three section averages, 2 dp, already
only once all three exist. Returns None when neither evaluation exists. ranged onto 0100) appears only once all three exist. Returns None when
neither evaluation exists. Legacy 14 section averages are converted
through to_percent so mixed old/new rows stay comparable.
""" """
latest = {} latest = {}
for row in rows: for row in rows:
@ -351,7 +375,7 @@ def combined_summary(rows):
for section in row.sections or []: for section in row.sections or []:
key = section.get("key") key = section.get("key")
if key in averages: if key in averages:
averages[key] = section.get("average") averages[key] = to_percent(section.get("average"))
complete = all(v is not None for v in averages.values()) complete = all(v is not None for v in averages.values())
return { return {

View File

@ -1,3 +1,24 @@
from candidate_forms.plugins import to_percent
def _sections_as_percent(sections):
if not sections:
return list(sections) if sections else None
out = []
for section in sections:
item = dict(section)
if "average" in item:
item["average"] = to_percent(item.get("average"))
criteria = item.get("criteria")
if criteria:
item["criteria"] = [
{**c, "rating": to_percent(c.get("rating"))} if isinstance(c, dict) else c
for c in criteria
]
out.append(item)
return out
def serialize_form( def serialize_form(
row, row,
*, *,
@ -21,12 +42,74 @@ def serialize_form(
"interviewer_id": str(row.interviewer_id) if row.interviewer_id else None, "interviewer_id": str(row.interviewer_id) if row.interviewer_id else None,
"interviewer_name": interviewer_name, "interviewer_name": interviewer_name,
"form_date": row.form_date.isoformat() if row.form_date else None, "form_date": row.form_date.isoformat() if row.form_date else None,
"sections": list(row.sections) if row.sections else None, "sections": _sections_as_percent(row.sections),
"fields": dict(row.fields) if row.fields else {}, "fields": dict(row.fields) if row.fields else {},
"overall_score": row.overall_score, "overall_score": to_percent(row.overall_score),
"recommendation": row.recommendation, "recommendation": row.recommendation,
"created_by": str(row.created_by) if row.created_by else None, "created_by": str(row.created_by) if row.created_by else None,
"created_by_name": created_by_name, "created_by_name": created_by_name,
"created_at": row.created_at.isoformat() if row.created_at 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, "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,8 +5,9 @@ from datetime import timezone
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession 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 ( from candidate_forms.plugins import (
FORM_DEFINITIONS,
FORM_READY_STATUSES, FORM_READY_STATUSES,
FORM_TYPES, FORM_TYPES,
RECOMMENDATIONS, RECOMMENDATIONS,
@ -14,13 +15,19 @@ from candidate_forms.plugins import (
normalize_fields, normalize_fields,
normalize_sections, 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 inbox.models import Inbox
from job.candidate.models import Interviews, Manual_UPLOAD_CANDIDATE from job.candidate.models import Interviews, Manual_UPLOAD_CANDIDATE
from job.candidate.views import assert_manager_candidate_access
from job.history.enums import HistoryEvent from job.history.enums import HistoryEvent
from job.history.views import HistoryRecorder from job.history.views import HistoryRecorder
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
from users.models import Users from users.models import Users
from users.permissions import is_admin, is_hiring_manager
logger = logging.getLogger("candidate_forms") logger = logging.getLogger("candidate_forms")
@ -53,6 +60,35 @@ def _stage_value(status) -> str:
return str(getattr(status, "value", status) or "").upper() return str(getattr(status, "value", status) or "").upper()
def _recommendation(form_type, value):
definition = FORM_DEFINITIONS.get(form_type) or {}
if not definition.get("has_recommendation"):
return None
if value in (None, ""):
return None
value = str(value).strip()
if value not in RECOMMENDATIONS:
raise HTTPException(
status_code=422,
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
)
return value
def _score_sections(form_type, sections):
try:
return normalize_sections(form_type, sections)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
def _score_fields(form_type, fields):
try:
return normalize_fields(form_type, fields)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
class CandidateForm: class CandidateForm:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
@ -80,55 +116,28 @@ class CandidateForm:
stage = _stage_value( stage = _stage_value(
link.messages.application_status if link.messages is not None else None link.messages.application_status if link.messages is not None else None
) )
app_job = (
link.messages.assigned_job_post_id if link.messages is not None else None
)
else: else:
inbox_id = None inbox_id = None
manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id) manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id)
if manual is None: if manual is None:
raise HTTPException(status_code=404, detail="Manual upload candidate not found") raise HTTPException(status_code=404, detail="Manual upload candidate not found")
stage = _stage_value(manual.status) stage = _stage_value(manual.status)
app_job = manual.job_post_id
job_post_id = _as_uuid(payload.get("job_post_id")) job_post_id = _as_uuid(payload.get("job_post_id"))
if payload.get("job_post_id") and job_post_id is None: if payload.get("job_post_id") and job_post_id is None:
raise HTTPException(status_code=422, detail="Invalid job_post_id") raise HTTPException(status_code=422, detail="Invalid job_post_id")
if job_post_id is None:
job_post_id = app_job
if job_post_id is not None: if job_post_id is not None:
post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id)) post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id))
if not post or post.is_deleted: if not post or post.is_deleted:
raise HTTPException(status_code=404, detail="Job post not found") raise HTTPException(status_code=404, detail="Job post not found")
return inbox_id, manual_id, job_post_id, stage 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): async def _context_maps(self, rows):
inbox_ids = [r.inbox_id for r in rows if r.inbox_id is not None] 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] manual_ids = [r.manual_upload_candidate_id for r in rows if r.manual_upload_candidate_id]
@ -213,11 +222,27 @@ class CandidateForm:
form_type=None, form_type=None,
top=None, top=None,
skip=0, skip=0,
current_user=None,
): ):
if form_type and form_type not in FORM_TYPES: if form_type and form_type not in FORM_TYPES:
raise HTTPException( raise HTTPException(
status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}" status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}"
) )
if is_hiring_manager(current_user) and not (
form_id or inbox_id is not None or manual_upload_candidate_id or job_post_id
):
raise HTTPException(
status_code=403,
detail="Hiring managers can only load forms for candidates on their requisitions",
)
if inbox_id is not None or manual_upload_candidate_id is not None or job_post_id:
await assert_manager_candidate_access(
self.session,
current_user,
job_post_id=job_post_id,
inbox_id=inbox_id,
manual_id=manual_upload_candidate_id,
)
rows, total = await CandidateForms.fetch_forms( rows, total = await CandidateForms.fetch_forms(
self.session, self.session,
form_id=form_id, form_id=form_id,
@ -228,6 +253,14 @@ class CandidateForm:
top=top, top=top,
skip=skip or 0, skip=skip or 0,
) )
if form_id and rows:
await assert_manager_candidate_access(
self.session,
current_user,
job_post_id=rows[0].job_post_id,
inbox_id=rows[0].inbox_id,
manual_id=rows[0].manual_upload_candidate_id,
)
summary = None summary = None
if inbox_id is not None or manual_upload_candidate_id is not None: if inbox_id is not None or manual_upload_candidate_id is not None:
@ -250,11 +283,14 @@ class CandidateForm:
status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}" status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}"
) )
inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload) inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload)
await assert_manager_candidate_access(
self.session,
current_user,
job_post_id=job_post_id,
inbox_id=inbox_id,
manual_id=manual_id,
)
if stage not in FORM_READY_STATUSES: 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 has_interview = False
if inbox_id is not None: if inbox_id is not None:
rows = await Interviews.get_interviews_by_inbox(self.session, inbox_id) rows = await Interviews.get_interviews_by_inbox(self.session, inbox_id)
@ -269,28 +305,29 @@ class CandidateForm:
), ),
) )
fields = { interviewer_id = _as_uuid(payload.get("interviewer_id"))
"inbox_id": inbox_id, if form_type != "requisition" and interviewer_id is None:
"manual_upload_candidate_id": manual_id, interviewer_id = _user_id(current_user)
"job_post_id": job_post_id, form_date = _aware(payload.get("form_date")) or _now()
"form_type": form_type, sections, overall_score = _score_sections(form_type, payload.get("sections"))
"created_by": _user_id(current_user), fields = _score_fields(form_type, payload.get("fields"))
}
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()
row = await CandidateForms.insert_form(self.session, fields) row = await CandidateForms.insert_form(
self.session,
{
"form_type": form_type,
"inbox_id": inbox_id,
"manual_upload_candidate_id": manual_id,
"job_post_id": job_post_id,
"interviewer_id": interviewer_id,
"form_date": form_date,
"sections": sections,
"fields": fields,
"overall_score": overall_score,
"recommendation": _recommendation(form_type, payload.get("recommendation")),
"created_by": _user_id(current_user),
},
)
await HistoryRecorder(self.session).record( await HistoryRecorder(self.session).record(
HistoryEvent.FORM_CREATED, HistoryEvent.FORM_CREATED,
current_user=current_user, current_user=current_user,
@ -308,8 +345,27 @@ class CandidateForm:
row = await CandidateForms.get_form_by_id(self.session, form_id) row = await CandidateForms.get_form_by_id(self.session, form_id)
if not row: if not row:
raise HTTPException(status_code=404, detail="Form not found") raise HTTPException(status_code=404, detail="Form not found")
await assert_manager_candidate_access(
self.session,
current_user,
job_post_id=row.job_post_id,
inbox_id=row.inbox_id,
manual_id=row.manual_upload_candidate_id,
)
fields = self._normalize_payload(row.form_type, payload) fields = {}
if "interviewer_id" in payload:
fields["interviewer_id"] = _as_uuid(payload.get("interviewer_id"))
if "form_date" in payload:
fields["form_date"] = _aware(payload.get("form_date"))
if "sections" in payload:
sections, overall_score = _score_sections(row.form_type, payload.get("sections"))
fields["sections"] = sections
fields["overall_score"] = overall_score
if "fields" in payload:
fields["fields"] = _score_fields(row.form_type, payload.get("fields"))
if "recommendation" in payload:
fields["recommendation"] = _recommendation(row.form_type, payload.get("recommendation"))
if not fields: if not fields:
raise HTTPException(status_code=400, detail="No fields to update") raise HTTPException(status_code=400, detail="No fields to update")
@ -330,7 +386,57 @@ class CandidateForm:
async def delete_form(self, form_id, current_user): async def delete_form(self, form_id, current_user):
_user_id(current_user) _user_id(current_user)
row = await CandidateForms.get_form_by_id(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
await assert_manager_candidate_access(
self.session,
current_user,
job_post_id=row.job_post_id,
inbox_id=row.inbox_id,
manual_id=row.manual_upload_candidate_id,
)
row = await CandidateForms.soft_delete_form(self.session, form_id) row = await CandidateForms.soft_delete_form(self.session, form_id)
if not row: if not row:
raise HTTPException(status_code=404, detail="Form not found") raise HTTPException(status_code=404, detail="Form not found")
return {"id": str(row.id), "deleted": True} return {"id": str(row.id), "deleted": True}
class RequisitionForm:
def __init__(self, session: AsyncSession):
self.session = session
async def create_form(self, payload, current_user):
payload["created_by"] = _user_id(current_user)
row = await Requisition.insert_form(self.session, payload)
return serialize_requisition(row)
async def update_form(self, form_id, payload, current_user):
_user_id(current_user)
row = await Requisition.get_form_by_id(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
if not payload:
raise HTTPException(status_code=400, detail="No fields to update")
updated = await Requisition.update_form(self.session, form_id, payload)
if not updated:
raise HTTPException(status_code=404, detail="Form not found")
return serialize_requisition(updated)
async def get_form_by_id(self, form_id, current_user):
# Admins see the full table. Managers still only see rows they opened.
# Job-post linkage is ignored here — that filter is search/picker only.
created_by = None if is_admin(current_user) else _user_id(current_user)
if form_id:
row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
return serialize_requisition(row)
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
return [serialize_requisition(r) for r in rows]
async def search(self, q, top=50, job_post_id=None):
rows = await Requisition.search(
self.session, q, top=top, job_post_id=job_post_id,
)
return [serialize_requisition_option(r) for r in rows]

View File

@ -5,7 +5,7 @@
"refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8", "refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8",
"universe_domain": "googleapis.com", "universe_domain": "googleapis.com",
"account": "ahmed.mujtaba@utopiabrands.com", "account": "ahmed.mujtaba@utopiabrands.com",
"token": "ya29.a0AdMD6Eg_6meQs84gTmiyhzZp7C-JlZeJU6-ECm6twwAcMqvfvRvyvs5LQGbAhHarHzZF-jiU-sicebJmxXIN4l6hNDXoaHcrojuhq--hj2oSBWojiEKaGIgLKPM8frdspz_wVANrwkFwpIKhN3RpWID9mJCt7N6IFaNrZtgStakdF0sVCKKVttE7qWK0vIvJT3HZHpVbaCgYKAX8SARASFQHGX2MiQvJqWStYQDgYFK4E6GJ9Zw0207", "token": "ya29.a0AdMD6EgILeNb9UszC7bQJbAcqX709J5ky3eM8MEuQayGwhDStmfnR5t7o192x-FPdt53Q29rYL69zqYrgofUqpwxoI_sPjBsb0wrLqYDo6zwJMTx4P5svM4jZJd9nrXzUEyp3uI81e9DQ3z6lIDKtm6aUTPWQ3fm33i2hxJM7i-svhi3OwjnLtpOUHDyw--v8rQLPHdfaCgYKATkSARASFQHGX2MiXDnutGLllH9DnNCBUKWi4Q0207",
"expiry": "2026-08-31T09:52:09Z", "expiry": "2026-09-02T08:29:45Z",
"quota_project_id": "hrms-ats-portal" "quota_project_id": "hrms-ats-portal"
} }

View File

@ -74,7 +74,7 @@ class Inbox(SQLModel, table=True):
) )
@classmethod @classmethod
async def get_all(cls,session:AsyncSession,job_post_id=None,limit=None,offset=0): async def get_all(cls,session:AsyncSession,job_post_id=None,job_post_ids=None,limit=None,offset=0):
try: try:
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
qry=( qry=(
@ -116,7 +116,12 @@ class Inbox(SQLModel, table=True):
cls.id.desc(), cls.id.desc(),
) )
) )
if job_post_id: if job_post_ids is not None:
ids=list(job_post_ids)
if not ids:
return []
qry=qry.where(Inbox_Messages.assigned_job_post_id.in_(ids))
elif job_post_id:
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id) qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
if limit is not None: if limit is not None:
qry=qry.limit(limit).offset(offset) qry=qry.limit(limit).offset(offset)

View File

@ -12,7 +12,7 @@ from job.history.views import HistoryRecorder
from job.assignment.views import Assignment from job.assignment.views import Assignment
from job.cost.views import HiringCost from job.cost.views import HiringCost
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from users.permissions import PermissionTag, require_permission from users.permissions import PermissionTag, is_hiring_manager, require_permission
from job.job_post.views import JobPost,JobPostCreate from job.job_post.views import JobPost,JobPostCreate
from job_assist.execute_agent import run_field_assist from job_assist.execute_agent import run_field_assist
from job.job_post.export import build_jobs_workbook from job.job_post.export import build_jobs_workbook
@ -140,6 +140,7 @@ class JobUpdate(BaseModel):
description: str | None = None description: str | None = None
current_recruiter_id: UUID | None = None current_recruiter_id: UUID | None = None
hiring_manager_id: UUID | None = None hiring_manager_id: UUID | None = None
requisition_id: UUID | None = None
class JobStatusUpdate(BaseModel): class JobStatusUpdate(BaseModel):
@ -235,9 +236,16 @@ async def fetch_users(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
try: try:
if is_hiring_manager(current_user):
raise HTTPException(
status_code=403,
detail="Hiring managers can only list candidates on their requisitions",
)
service=User(session=session) service=User(session=session)
data=await service.get_users(role_id=role_id,top=top,skip=skip) data=await service.get_users(role_id=role_id,top=top,skip=skip)
return JSONResponse(content={"data":data,"status_code":200}) return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@ -251,9 +259,16 @@ async def count_candidate_users(
): ):
"""Total matching users for the Candidates pager. Called once on page open.""" """Total matching users for the Candidates pager. Called once on page open."""
try: try:
if is_hiring_manager(current_user):
raise HTTPException(
status_code=403,
detail="Hiring managers can only list candidates on their requisitions",
)
service=User(session=session) service=User(session=session)
total=await service.count_users(search=search,role_id=role_id) total=await service.count_users(search=search,role_id=role_id)
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200}) return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@ -727,6 +742,46 @@ async def fetch_job_posts(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/stats/fetch")
async def fetch_job_stats(
job_post_id: Optional[uuid.UUID] = Query(None),
search: str | None = Query(None),
ids: str | None = Query(None),
top: int | None = Query(10, ge=1, le=500),
skip: int = Query(0, ge=0),
active_only: bool = Query(False),
current_user: dict = Depends(
require_permission(
PermissionTag.JOBS_VIEW,
PermissionTag.PIPELINE_VIEW,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
"""Live pipeline-stage counts per job post (inbox + manual upload).
Omit job_post_id for a paged list (optional search / ids). Pass job_post_id
for a single object. Counts are aggregated, not stored.
"""
try:
service=JobPost(session=session)
id_list=[x.strip() for x in (ids or "").split(",") if x.strip()] or None
data,total=await service.fetch_job_stats(
job_post_id=job_post_id,
search=search,
ids=id_list,
top=top,
skip=skip,
active_only=active_only,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/departments/fetch") @router.get("/job/departments/fetch")
async def fetch_job_departments( async def fetch_job_departments(
active_only: bool = Query(False), active_only: bool = Query(False),
@ -864,6 +919,25 @@ async def fetch_candidate_by_id(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/manager/fetch")
async def fetch_manager_candidates(
limit:int=Query(50,ge=1,le=200),
offset:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Candidates allocated to jobs opened from this user's requisitions
(or where they are the assigned hiring manager)."""
try:
service=CandidateView(session=session)
data,total=await service.list_manager_candidates(current_user,limit=limit,offset=offset)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch") @router.get("/candidate/fetch")
async def fetch_candidate( async def fetch_candidate(
user_id:str=Query(None), user_id:str=Query(None),
@ -875,7 +949,9 @@ async def fetch_candidate(
): ):
try: try:
service=CandidateView(session=session) service=CandidateView(session=session)
data=await service.get_candidate(user_id=user_id,limit=limit,offset=offset,search=search) data=await service.get_candidate(
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,
)
total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1 total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1
@ -998,7 +1074,7 @@ async def fetch_notes(
): ):
try: try:
service=Note(session=session) service=Note(session=session)
data=await service.get_note(note_id=note_id,user_id=user_id) data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user)
total=1 if isinstance(data,dict) else len(data) total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException: except HTTPException:

View File

@ -50,9 +50,8 @@ class Assignment:
async def record_job_owner(self,job_post_id,user_id,assignment_role,assigned_by): 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. """Close the open interval of this role, then open a new one.
user_id None = unassign (hiring_manager cannot be cleared; callers user_id None = unassign. No-ops when the same person already holds the
must not pass None for that role). No-ops when the same person already open interval. Does not touch job_posts columns.
holds the open interval. Does not touch job_posts columns.
""" """
role=self._job_role(assignment_role) role=self._job_role(assignment_role)
job_uid=JobAssignments._as_uuid(job_post_id) job_uid=JobAssignments._as_uuid(job_post_id)
@ -63,8 +62,6 @@ class Assignment:
self.session,job_uid,current_only=True,assignment_role=role, self.session,job_uid,current_only=True,assignment_role=role,
) )
if user_id is None: 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) await JobAssignments.close_current(self.session,job_uid,role)
return None return None
user_uid=JobAssignments._as_uuid(user_id) user_uid=JobAssignments._as_uuid(user_id)

View File

@ -62,7 +62,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod @classmethod
async def get_all(cls, session: AsyncSession, job_post_id=None, limit=None, offset=0): async def get_all(cls, session: AsyncSession, job_post_id=None, job_post_ids=None, limit=None, offset=0):
try: try:
from inbox.models import AtsResults from inbox.models import AtsResults
from users.models import Users from users.models import Users
@ -111,7 +111,12 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
cls.id.desc(), cls.id.desc(),
) )
) )
if job_post_id: if job_post_ids is not None:
ids=list(job_post_ids)
if not ids:
return []
qry=qry.where(cls.job_post_id.in_(ids))
elif job_post_id:
qry=qry.where(cls.job_post_id==job_post_id) qry=qry.where(cls.job_post_id==job_post_id)
if limit is not None: if limit is not None:
qry=qry.limit(limit).offset(offset) qry=qry.limit(limit).offset(offset)

View File

@ -220,3 +220,24 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
"summary_critique": None, "summary_critique": None,
"scored_at": None, "scored_at": None,
} }
def serialize_manager_candidate(row, *, source) -> dict:
"""One application on a hiring-manager's job — list row, not the profile."""
inbox_id = row.get("inbox_id")
manual_id = row.get("id") if source == "manual" else None
job_post_id = row.get("assigned_job_post_id") or row.get("job_post_id")
user_id = row.get("user_id")
return {
"id": user_id or (f"inbox:{inbox_id}" if inbox_id is not None else f"manual:{manual_id}"),
"user_id": user_id,
"name": row.get("name"),
"email": row.get("email") or row.get("candidate_email"),
"job_post_id": job_post_id,
"job_title": row.get("title"),
"application_status": row.get("application_status"),
"inbox_id": inbox_id,
"manual_upload_candidate_id": str(manual_id) if manual_id else None,
"created_at": row.get("created_at"),
"source": source,
}

View File

@ -23,7 +23,7 @@ from job.candidate.plugins import (
get_scoring_settings, get_scoring_settings,
normalize_spaced_text, normalize_spaced_text,
) )
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post from job.job_post.serializers import serialize_job_post
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
@ -32,6 +32,7 @@ from job.history.views import HistoryRecorder
from job.notes.serializers import serialize_note from job.notes.serializers import serialize_note
from job.candidate.plugins import extract_candidate_email from job.candidate.plugins import extract_candidate_email
from users.models import Users from users.models import Users
from users.permissions import is_hiring_manager
from employment_agent.plugins import parse_phone from employment_agent.plugins import parse_phone
load_dotenv() load_dotenv()
@ -40,6 +41,62 @@ CV_QUEUE_NAME=os.getenv("TASKIQ_CV_QUEUE_NAME","cv_upload")
MANUAL_UPLOAD_TO_ADDRESS=os.getenv( MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
"MANUAL_UPLOAD_TO_ADDRESS","manual-cv-upload@hr-ats.local" "MANUAL_UPLOAD_TO_ADDRESS","manual-cv-upload@hr-ats.local"
) )
MANAGER_SCOPE_DETAIL="You can only access candidates allocated to jobs opened from your requisitions"
async def assigned_job_ids_for_user(session,user_id):
"""Job posts this candidate is allocated to (inbox assignment + manual upload)."""
ids=set()
if not user_id:
return ids
rows=await Inbox.get_candidate_profile(session=session,user_id=user_id,limit=1000,offset=0)
records=rows if isinstance(rows,list) else ([rows] if rows else [])
for rec in records:
msg=getattr(rec,"messages",None)
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
if jid:
ids.add(jid)
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(session,user_id)
if manual and manual.job_post_id:
ids.add(manual.job_post_id)
return ids
async def job_id_for_application(session,inbox_id=None,manual_id=None):
if inbox_id is not None:
link=await Inbox.get_inbox_with_message(session,inbox_id)
if link is None:
return None,None
msg=link.messages
return (msg.assigned_job_post_id if msg is not None else None),link.user_id
if manual_id is not None:
manual=await Manual_UPLOAD_CANDIDATE.get_by_id(session,manual_id)
if manual is None:
return None,None
return manual.job_post_id,manual.user_id
return None,None
async def assert_manager_candidate_access(
session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None,
):
"""Hiring managers may only touch applications on jobs they own."""
if not is_hiring_manager(current_user):
return
owned=set(await JobPosts.ids_for_manager(session,current_user.get("id")))
if not owned:
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
job_id=JobPosts._as_uuid(job_post_id) if job_post_id is not None else None
uid=user_id
if job_id is None and (inbox_id is not None or manual_id is not None):
job_id,uid=await job_id_for_application(session,inbox_id=inbox_id,manual_id=manual_id)
if job_id is None and uid is not None:
candidate_jobs=await assigned_job_ids_for_user(session,uid)
if candidate_jobs & owned:
return
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
if job_id is None or job_id not in owned:
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
async def parse_linkedin_url_from_cv(resume_text) -> str | None: async def parse_linkedin_url_from_cv(resume_text) -> str | None:
@ -697,14 +754,62 @@ class CandidateView:
logger.exception("manual candidate rollback failed for %s",getattr(row,"id",None)) logger.exception("manual candidate rollback failed for %s",getattr(row,"id",None))
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None): async def list_manager_candidates(self,current_user,limit=50,offset=0):
"""Candidates allocated to jobs this manager owns (requisition → job post)."""
job_ids=await JobPosts.ids_for_manager(self.session,current_user.get("id"))
if not job_ids:
return [],0
inbox_rows=await Inbox.get_all(self.session,job_post_ids=job_ids)
manual_rows=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_ids=job_ids)
merged=[]
seen=set()
for row in inbox_rows:
uid=row.get("user_id")
payload=serialize_manager_candidate(row,source="inbox")
if uid and uid not in seen:
seen.add(uid)
merged.append(payload)
elif not uid:
merged.append(payload)
for row in manual_rows:
uid=row.get("user_id")
if uid and uid in seen:
continue
payload=serialize_manager_candidate(row,source="manual")
if uid:
seen.add(uid)
merged.append(payload)
merged.sort(key=lambda r: r.get("created_at") or "",reverse=True)
total=len(merged)
start=max(0,int(offset or 0))
cap=max(1,int(limit or 50))
return merged[start:start+cap],total
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None):
try: try:
if not user_id and is_hiring_manager(current_user):
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
if user_id and is_hiring_manager(current_user):
await assert_manager_candidate_access(
self.session,current_user,user_id=user_id,
)
detail=bool(user_id) detail=bool(user_id)
# Detail mode must see every application for the candidate, not one page. # Detail mode must see every application for the candidate, not one page.
fetch_limit=1000 if detail else limit fetch_limit=1000 if detail else limit
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search) rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search)
if detail: if detail:
records=rows if isinstance(rows,list) else ([rows] if rows else []) records=rows if isinstance(rows,list) else ([rows] if rows else [])
if records and is_hiring_manager(current_user):
owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id")))
kept=[]
for rec in records:
msg=getattr(rec,"messages",None)
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
if jid and jid in owned:
kept.append(rec)
if kept:
return await self.attach_profile_detail(kept)
records=[]
if records: if records:
return await self.attach_profile_detail(rows) return await self.attach_profile_detail(rows)
# Manual uploads create users + manual_upload_candidate but no inbox # Manual uploads create users + manual_upload_candidate but no inbox
@ -712,6 +817,10 @@ class CandidateView:
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id) manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
if not manual: if not manual:
return [] return []
if is_hiring_manager(current_user):
owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id")))
if not manual.job_post_id or manual.job_post_id not in owned:
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
user=await Users.get_user_by_id(self.session,user_id) user=await Users.get_user_by_id(self.session,user_id)
job_post=None job_post=None
if manual.job_post_id: if manual.job_post_id:

View File

@ -2,13 +2,15 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, JSON, Index, func, or_ from sqlalchemy import DateTime, JSON, Index, String, case, cast, func, or_, union_all
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from sqlmodel import Field, Relationship, SQLModel, select from sqlmodel import Field, Relationship, SQLModel, select
from job.job_post.enums import RequisitionStatus from job.job_post.enums import RequisitionStatus
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
from candidate_forms.models import Requisition
from users.models import Users from users.models import Users
@ -22,12 +24,7 @@ class JobPosts(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str = Field(index=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( user: Optional["Users"] = Relationship(
back_populates="job_posts", back_populates="job_posts",
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"}, sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
@ -51,9 +48,7 @@ class JobPosts(SQLModel, table=True):
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
status: str = Field(default="draft") status: str = Field(default="draft")
buffer_error: str | None = Field(default=None) buffer_error: str | None = Field(default=None)
# requisition_status is the hiring lifecycle (RequisitionStatus). Distinct from
# `status`, which tracks Buffer publishing (draft/scheduled/published/failed).
# server_default is load-bearing: this column arrives as an ALTER on a populated table.
requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"}) requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"})
department: str = Field(default="", sa_column_kwargs={"server_default": ""}) department: str = Field(default="", sa_column_kwargs={"server_default": ""})
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
@ -61,9 +56,19 @@ class JobPosts(SQLModel, table=True):
# Who is working the req now (swappable). History lives in job_assignments # Who is working the req now (swappable). History lives in job_assignments
# with assignment_role=primary_recruiter; this column is the current pointer. # with assignment_role=primary_recruiter; this column is the current pointer.
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") 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. # job_assignments with assignment_role=hiring_manager.
hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True) 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_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@ -83,6 +88,20 @@ class JobPosts(SQLModel, table=True):
result = await session.execute(select(cls).where(cls.id == uid)) result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first() 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 @classmethod
async def get_active_job_posts(cls, session: AsyncSession): async def get_active_job_posts(cls, session: AsyncSession):
result = await session.execute( result = await session.execute(
@ -177,6 +196,193 @@ class JobPosts(SQLModel, table=True):
result = await session.execute(statement) result = await session.execute(statement)
return list(result.scalars().all()), total return list(result.scalars().all()), total
@classmethod
async def fetch_job_stats(
cls,
session: AsyncSession,
*,
job_post_id=None,
search: str | None = None,
ids: list[str] | None = None,
top: int | None = None,
skip: int = 0,
active_only: bool = False,
):
"""Per-job pipeline stage counts for every applicant assigned to the job.
Inbox, manual-upload / Add Candidate / CV-bank, and unpromoted sheet
rows. Duplicate emails (case-insensitive) count once per job the
furthest pipeline stage is kept. Flagged is_duplicate rows are skipped.
Rows with no email still count, each as themselves. Jobs with zero
applicants still appear (LEFT JOIN).
"""
from g_sheet.models import FormData
from inbox.models import Inbox_Messages
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from users.models import Users
job_uids = []
if job_post_id is not None:
uid = job_post_id if isinstance(job_post_id, uuid.UUID) else cls._as_uuid(job_post_id)
if uid is None:
return [], 0
job_uids = [uid]
elif ids:
for raw in ids:
uid = cls._as_uuid(raw)
if uid is not None:
job_uids.append(uid)
if not job_uids:
return [], 0
def dup_key(email_col, row_id):
# Same person = lower(trim(email)). No address -> unique per row
# so blank emails do not collapse into one applicant.
return func.coalesce(
func.nullif(func.lower(func.btrim(email_col)), ""),
func.concat("noid:", cast(row_id, String)),
)
inbox_q = (
select(
Inbox_Messages.assigned_job_post_id.label("job_post_id"),
dup_key(Inbox_Messages.message_from, Inbox_Messages.id).label("dup_key"),
cast(Inbox_Messages.application_status, String).label("stage"),
)
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
.where(Inbox_Messages.is_duplicate == False) # noqa: E712
)
manual_stage = func.coalesce(
func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.status), ""),
"PENDING",
)
manual_email = func.coalesce(
func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.candidate_email), ""),
Users.email,
)
manual_q = (
select(
Manual_UPLOAD_CANDIDATE.job_post_id.label("job_post_id"),
dup_key(manual_email, Manual_UPLOAD_CANDIDATE.id).label("dup_key"),
manual_stage.label("stage"),
)
.select_from(Manual_UPLOAD_CANDIDATE)
.outerjoin(Users, Users.id == Manual_UPLOAD_CANDIDATE.user_id)
.where(Manual_UPLOAD_CANDIDATE.job_post_id.is_not(None))
)
# Unpromoted sheet applicants only — promoted rows already live on
# manual_upload_candidate (manual_upload_candidate_id set).
form_stage = case(
(FormData.processing_state == "rejected", "REJECTED"),
else_="PENDING",
)
form_q = (
select(
FormData.job_post_id.label("job_post_id"),
dup_key(FormData.candidate_email, FormData.id).label("dup_key"),
form_stage.label("stage"),
)
.where(FormData.job_post_id.is_not(None))
.where(FormData.manual_upload_candidate_id.is_(None))
.where(FormData.is_duplicate == False) # noqa: E712
)
if job_uids:
inbox_q = inbox_q.where(Inbox_Messages.assigned_job_post_id.in_(job_uids))
manual_q = manual_q.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids))
form_q = form_q.where(FormData.job_post_id.in_(job_uids))
apps = union_all(inbox_q, manual_q, form_q).subquery("applications")
stage_rank = case(
(apps.c.stage == "HIRED", 9),
(apps.c.stage == "APPROVED", 8),
(apps.c.stage == "OFFER", 7),
(apps.c.stage == "INTERVIEW", 6),
(apps.c.stage == "ASSESSMENT", 5),
(apps.c.stage.in_(["SCREENING", "PROCESS"]), 4),
(apps.c.stage == "PENDING", 3),
(apps.c.stage == "ONHOLD", 2),
(apps.c.stage.in_(["REJECTED", "CLOSED"]), 1),
else_=0,
)
unique_apps = (
select(apps.c.job_post_id, apps.c.dup_key, apps.c.stage)
.distinct(apps.c.job_post_id, apps.c.dup_key)
.order_by(apps.c.job_post_id, apps.c.dup_key, stage_rank.desc())
.subquery("unique_applicants")
)
stage = unique_apps.c.stage
def stage_count(*values):
return func.coalesce(func.sum(case((stage.in_(list(values)), 1), else_=0)), 0)
stats = (
select(
unique_apps.c.job_post_id,
func.count().label("total_applicants"),
stage_count("PENDING").label("shortlisting"),
stage_count("SCREENING", "PROCESS").label("screened"),
stage_count("ASSESSMENT").label("assessment"),
stage_count("INTERVIEW").label("interviewed"),
stage_count("OFFER").label("offered"),
stage_count("ONHOLD").label("on_hold"),
stage_count("REJECTED", "CLOSED").label("rejected"),
stage_count("APPROVED").label("approved"),
stage_count("HIRED").label("hired"),
)
.select_from(unique_apps)
.group_by(unique_apps.c.job_post_id)
.subquery("job_stage_stats")
)
# Alias so this join does not collide with the Users join inside
# the manual-upload subquery above.
Recruiter=aliased(Users)
statement = (
select(
cls.id.label("job_post_id"),
cls.title,
cls.department,
cls.location,
cls.requisition_status,
cls.current_recruiter_id,
Recruiter.name.label("recruiter_name"),
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
func.coalesce(stats.c.screened, 0).label("screened"),
func.coalesce(stats.c.assessment, 0).label("assessment"),
func.coalesce(stats.c.interviewed, 0).label("interviewed"),
func.coalesce(stats.c.offered, 0).label("offered"),
func.coalesce(stats.c.on_hold, 0).label("on_hold"),
func.coalesce(stats.c.rejected, 0).label("rejected"),
func.coalesce(stats.c.approved, 0).label("approved"),
func.coalesce(stats.c.hired, 0).label("hired"),
)
.select_from(cls)
.outerjoin(stats, stats.c.job_post_id == cls.id)
.outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id)
.where(cls.is_deleted == False) # noqa: E712
)
if active_only:
statement = statement.where(cls.is_active == True) # noqa: E712
if job_uids:
statement = statement.where(cls.id.in_(job_uids))
if search:
like = f"%{search.strip()}%"
statement = statement.where(
or_(cls.title.ilike(like), cls.location.ilike(like), cls.department.ilike(like))
)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.created_at.desc())
if job_post_id is None:
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.mappings().all()), int(total or 0)
@classmethod @classmethod
async def list_departments(cls, session: AsyncSession, *, active_only: bool = False): async def list_departments(cls, session: AsyncSession, *, active_only: bool = False):
"""Distinct non-empty departments on non-deleted job posts. """Distinct non-empty departments on non-deleted job posts.
@ -195,6 +401,39 @@ class JobPosts(SQLModel, table=True):
result = await session.execute(statement) result = await session.execute(statement)
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod
async def ids_for_manager(cls, session: AsyncSession, user_id):
"""Job posts this user owns: assigned hiring_manager, or opened from
a requisition they created. The manager Candidates list and form
scope both follow this chain."""
uid = cls._as_uuid(user_id)
if uid is None:
return []
from candidate_forms.models import Requisition
assigned = await session.execute(
select(cls.id).where(
cls.hiring_manager_id == uid,
cls.is_deleted == False, # noqa: E712
)
)
via_req = await session.execute(
select(cls.id)
.join(Requisition, cls.requisition_id == Requisition.id)
.where(
Requisition.created_by == uid,
Requisition.is_deleted == False, # noqa: E712
cls.is_deleted == False, # noqa: E712
)
)
seen: set[uuid.UUID] = set()
out: list[uuid.UUID] = []
for row_id in list(assigned.scalars().all()) + list(via_req.scalars().all()):
if row_id not in seen:
seen.add(row_id)
out.append(row_id)
return out
@classmethod @classmethod
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids): async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
"""Open requisitions per hiring manager, keyed by users.id.""" """Open requisitions per hiring manager, keyed by users.id."""
@ -390,6 +629,7 @@ class JobPosts(SQLModel, table=True):
return None return None
row.is_deleted = True row.is_deleted = True
row.is_active = False row.is_active = False
row.requisition_id = None
row.updated_at = _now() row.updated_at = _now()
session.add(row) session.add(row)
await session.commit() await session.commit()
@ -543,4 +783,7 @@ class SocialPlatform(SQLModel, table=True):
return {r.alias: r.buffer_service for r in rows} 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_by_name": row.user.name if getattr(row, "user", None) else None,
"created_at": row.created_at.isoformat() if row.created_at 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, "updated_at": row.updated_at.isoformat() if row.updated_at else None,
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
} }
@ -46,6 +47,7 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
inbox, candidate and matching paths. department is the one shared field inbox, candidate and matching paths. department is the one shared field
talent-pool filters key off it on attached job_posts. talent-pool filters key off it on attached job_posts.
""" """
req = getattr(row, "requisition", None)
return { return {
"id": str(row.id), "id": str(row.id),
"title": row.title, "title": row.title,
@ -70,6 +72,9 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
"recruiter_name": recruiter_name, "recruiter_name": recruiter_name,
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None, "hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
"hiring_manager_name": hiring_manager_name, "hiring_manager_name": hiring_manager_name,
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
"requisition_title": req.position_title if req else None,
"requisition_department": req.department if req else None,
"applicant_count": applicant_count, "applicant_count": applicant_count,
"created_by": str(row.created_by) if row.created_by else None, "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, "created_by_name": row.user.name if getattr(row, "user", None) else None,
@ -78,6 +83,30 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
} }
def serialize_job_stats(row) -> dict:
"""One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats."""
recruiter_id=row.get("current_recruiter_id")
return {
"job_post_id": str(row["job_post_id"]),
"title": row["title"],
"department": row["department"] or None,
"location": row["location"],
"requisition_status": row["requisition_status"],
"current_recruiter_id": str(recruiter_id) if recruiter_id else None,
"recruiter_name": row.get("recruiter_name") or None,
"total_applicants": int(row["total_applicants"] or 0),
"shortlisting": int(row["shortlisting"] or 0),
"screened": int(row["screened"] or 0),
"assessment": int(row["assessment"] or 0),
"interviewed": int(row["interviewed"] or 0),
"offered": int(row["offered"] or 0),
"on_hold": int(row["on_hold"] or 0),
"rejected": int(row["rejected"] or 0),
"approved": int(row["approved"] or 0),
"hired": int(row["hired"] or 0),
}
def serialize_status_history(row, *, changed_by_name=None) -> dict: def serialize_status_history(row, *, changed_by_name=None) -> dict:
return { return {
"id": str(row.id), "id": str(row.id),

View File

@ -8,6 +8,7 @@ from uuid import UUID
import httpx import httpx
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from inbox.models import Inbox_Messages from inbox.models import Inbox_Messages
@ -26,7 +27,7 @@ from job.job_post.plugins import (
render_job_post, render_job_post,
resolve_channel, resolve_channel,
) )
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_status_history from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history
load_dotenv() load_dotenv()
logger=logging.getLogger("job.job_post") logger=logging.getLogger("job.job_post")
@ -64,8 +65,9 @@ class JobPostCreate(BaseModel):
scheduler_time: time | None = time(0, 0, 0) scheduler_time: time | None = time(0, 0, 0)
scheduler_date: date | None = None scheduler_date: date | None = None
due_at: str | None = None due_at: str | None = None
hiring_manager_id: UUID hiring_manager_id: UUID | None = None
current_recruiter_id: UUID | None = None current_recruiter_id: UUID | None = None
requisition_id: UUID | None = None
@model_validator(mode="after") @model_validator(mode="after")
def validate_mode_and_due_at(self): def validate_mode_and_due_at(self):
@ -146,10 +148,12 @@ class JobPost:
fields["platform"]="internal" fields["platform"]="internal"
assignment=Assignment(self.session) assignment=Assignment(self.session)
hm=await assignment.require_role( hm=None
payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id", if payload.get("hiring_manager_id"):
) hm=await assignment.require_role(
fields["hiring_manager_id"]=hm.id payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id",
)
fields["hiring_manager_id"]=hm.id
rec=None rec=None
if payload.get("current_recruiter_id"): if payload.get("current_recruiter_id"):
rec=await assignment.require_role( rec=await assignment.require_role(
@ -157,9 +161,33 @@ class JobPost:
) )
fields["current_recruiter_id"]=rec.id 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 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: if rec:
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by) await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
@ -206,6 +234,28 @@ class JobPost:
) )
return [serialize_job_post(r) for r in rows],total return [serialize_job_post(r) for r in rows],total
async def fetch_job_stats(self,job_post_id=None,search=None,ids=None,top=None,skip=0,active_only=False):
uid=None
if job_post_id not in (None,""):
uid=JobPosts._as_uuid(job_post_id)
if uid is None:
raise HTTPException(status_code=422,detail="job_post_id must be a UUID")
rows,total=await JobPosts.fetch_job_stats(
self.session,
job_post_id=uid,
search=search,
ids=ids,
top=top,
skip=skip,
active_only=active_only,
)
data=[serialize_job_stats(r) for r in rows]
if uid is not None:
if not data:
raise HTTPException(status_code=404,detail="Job post not found")
return data[0],1
return data,total
async def fetch_departments(self,active_only=False): async def fetch_departments(self,active_only=False):
return await JobPosts.list_departments(self.session,active_only=active_only) return await JobPosts.list_departments(self.session,active_only=active_only)
@ -288,11 +338,29 @@ class JobPost:
rec_changed=False rec_changed=False
if "hiring_manager_id" in payload: if "hiring_manager_id" in payload:
raw=payload.get("hiring_manager_id") raw=payload.get("hiring_manager_id")
if not raw: if raw is None or raw=="":
raise HTTPException(status_code=422,detail="hiring_manager_id is required") fields["hiring_manager_id"]=None
hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id") hm_changed=existing.hiring_manager_id is not None
fields["hiring_manager_id"]=hm.id else:
hm_changed=str(existing.hiring_manager_id)!=str(hm.id) hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id")
fields["hiring_manager_id"]=hm.id
hm_changed=str(existing.hiring_manager_id)!=str(hm.id)
if "requisition_id" in payload:
raw=payload.get("requisition_id")
if raw is None or raw=="":
fields["requisition_id"]=None
else:
from candidate_forms.models import Requisition
req=await Requisition.get_form_by_id(self.session,record_id=str(raw))
if not req:
raise HTTPException(status_code=404,detail="Requisition not found")
held=await JobPosts.get_by_requisition_id(self.session, req.id)
if held and str(held.id)!=str(existing.id):
raise HTTPException(
status_code=409,
detail="This requisition is already linked to a job post",
)
fields["requisition_id"]=req.id
if "current_recruiter_id" in payload: if "current_recruiter_id" in payload:
raw=payload.get("current_recruiter_id") raw=payload.get("current_recruiter_id")
if raw is None or raw=="": if raw is None or raw=="":
@ -305,7 +373,15 @@ class JobPost:
if not fields: if not fields:
raise HTTPException(status_code=400,detail="No fields to update") 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: if not row:
raise HTTPException(status_code=404,detail="Job post not found") raise HTTPException(status_code=404,detail="Job post not found")
if hm_changed: if hm_changed:

View File

@ -2,6 +2,7 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from job.candidate.models import Notes from job.candidate.models import Notes
from job.candidate.views import assert_manager_candidate_access
from job.history.enums import HistoryEvent from job.history.enums import HistoryEvent
from job.history.views import HistoryRecorder from job.history.views import HistoryRecorder
from job.notes.serializers import serialize_note from job.notes.serializers import serialize_note
@ -14,17 +15,21 @@ class Note:
async def _load(self,record_id): async def _load(self,record_id):
return await Notes.get_note_by_id(self.session,record_id) return await Notes.get_note_by_id(self.session,record_id)
async def get_note(self,note_id=None,user_id=None): async def get_note(self,note_id=None,user_id=None,current_user=None):
if note_id: if note_id:
row=await self._load(note_id) row=await self._load(note_id)
if not row: if not row:
raise HTTPException(status_code=404,detail="Note not found") raise HTTPException(status_code=404,detail="Note not found")
await assert_manager_candidate_access(
self.session,current_user,user_id=row.user_id,
)
return serialize_note(row) return serialize_note(row)
if not user_id: if not user_id:
raise HTTPException(status_code=400,detail="note_id or user_id is required") raise HTTPException(status_code=400,detail="note_id or user_id is required")
uid=Notes._as_uuid(user_id) uid=Notes._as_uuid(user_id)
if uid is None: if uid is None:
raise HTTPException(status_code=400,detail="Invalid user_id") raise HTTPException(status_code=400,detail="Invalid user_id")
await assert_manager_candidate_access(self.session,current_user,user_id=uid)
rows=await Notes.get_notes_by_user(self.session,uid) rows=await Notes.get_notes_by_user(self.session,uid)
return [serialize_note(r) for r in rows] return [serialize_note(r) for r in rows]
@ -36,6 +41,9 @@ class Note:
} }
if not fields["user_id"]: if not fields["user_id"]:
raise HTTPException(status_code=400,detail="user_id is required") raise HTTPException(status_code=400,detail="user_id is required")
await assert_manager_candidate_access(
self.session,current_user,user_id=fields["user_id"],
)
row=await Notes.insert_note(self.session,fields) row=await Notes.insert_note(self.session,fields)
await HistoryRecorder(self.session).record( await HistoryRecorder(self.session).record(
HistoryEvent.NOTE_CREATED.value, HistoryEvent.NOTE_CREATED.value,
@ -53,6 +61,9 @@ class Note:
before=await self._load(note_id) before=await self._load(note_id)
if not before: if not before:
raise HTTPException(status_code=404,detail="Note not found") raise HTTPException(status_code=404,detail="Note not found")
await assert_manager_candidate_access(
self.session,current_user,user_id=before.user_id,
)
old_note=before.note or "" old_note=before.note or ""
row=await Notes.update_note(self.session,note_id,fields) row=await Notes.update_note(self.session,note_id,fields)
if not row: if not row:

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

@ -0,0 +1,40 @@
-- 024_manager_candidates_rbac.sql
-- Manual one-shot: hiring_manager can list candidates on their requisition
-- jobs and write notes on those profiles. Form fill already comes from
-- hiring_forms (interviews.create/edit) + analytics_dashboard (interviews.view).
-- Applied at startup by alembic_setup.run_manual_sql().
--
-- Users must log in again after this applies — the frontend caches /users/me.
-- =============================================================================
-- 1. Bundle: candidates.view / create / edit (list + notes)
-- =============================================================================
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
'manager_candidates',
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
(
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
FROM app.permission_tags
WHERE is_deleted = false
AND tag_name IN ('candidates.view', 'candidates.create', 'candidates.edit')
),
true,
NOW(),
NOW(),
true,
false
WHERE NOT EXISTS (
SELECT 1 FROM app.permissions WHERE name = 'manager_candidates'
);
-- =============================================================================
-- 2. Attach the bundle to hiring_manager only
-- =============================================================================
UPDATE app.roles r
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
updated_at = NOW()
FROM app.permissions p
WHERE p.name = 'manager_candidates'
AND r.role_name = 'hiring_manager'
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));

View File

@ -0,0 +1,34 @@
-- 025_manager_role_candidates_rbac.sql
-- The Access Control role named Manager (Ahmed Baig) is not the seeded
-- hiring_manager role. 024 only attached manager_candidates to hiring_manager,
-- so Manager had 0 candidates.* tags and the Candidates nav item never appeared
-- (routes.js permission is candidates.view). Same hole broke Calendar:
-- GET /interview/fetch is gated on candidates.view, not interviews.view.
-- Applied at startup by alembic_setup.run_manual_sql(). Log in again after.
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
'manager_candidates',
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
(
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
FROM app.permission_tags
WHERE is_deleted = false
AND tag_name IN ('candidates.view', 'candidates.create', 'candidates.edit')
),
true,
NOW(),
NOW(),
true,
false
WHERE NOT EXISTS (
SELECT 1 FROM app.permissions WHERE name = 'manager_candidates'
);
UPDATE app.roles r
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
updated_at = NOW()
FROM app.permissions p
WHERE p.name = 'manager_candidates'
AND lower(r.role_name) IN ('hiring_manager', 'manager')
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));

View File

@ -177,7 +177,7 @@ class Users(SQLModel, table=True):
@classmethod @classmethod
async def get_user_by_email(cls, session: AsyncSession, email: str): 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) result = await session.execute(statement)
return result.scalars().first() return result.scalars().first()

View File

@ -16,7 +16,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db_setup import get_session from db_setup import get_session
from role.models import Roles from role.models import EnumRoles, Roles
from users.models import Users from users.models import Users
from users.plugins import decode_token from users.plugins import decode_token
from users.serializers import serialize_user from users.serializers import serialize_user
@ -40,7 +40,7 @@ class PermissionModule(str, Enum):
RBAC_USERS = "rbac_users" RBAC_USERS = "rbac_users"
TASKS = "tasks" TASKS = "tasks"
TALENT = "talent" TALENT = "talent"
REQUISITIONS = "requisitions"
class PermissionAction(str, Enum): class PermissionAction(str, Enum):
VIEW = "view" VIEW = "view"
@ -54,6 +54,15 @@ class PermissionAction(str, Enum):
class PermissionTag(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_VIEW = "dashboard.view"
DASHBOARD_CREATE = "dashboard.create" DASHBOARD_CREATE = "dashboard.create"
DASHBOARD_EDIT = "dashboard.edit" DASHBOARD_EDIT = "dashboard.edit"
@ -194,6 +203,37 @@ def _assert_vocabulary_complete() -> None:
_assert_vocabulary_complete() _assert_vocabulary_complete()
def is_hiring_manager(current_user: dict | None) -> bool:
"""Hiring-manager portal: seeded hiring_manager, or a custom Manager role.
Ahmed Baig's Access Control role is named Manager (not hiring_manager).
Matching is case-insensitive so the sidebar and API scope agree.
"""
name = ((current_user or {}).get("role_name") or "").strip().lower()
return name in {EnumRoles.HIRING_MANAGER.value, "manager"}
_ADMIN_ROLES = {
EnumRoles.SYSTEM_ADMINISTRATOR.value,
EnumRoles.HR_ADMINISTRATOR.value,
"admin",
}
def is_admin(current_user: dict | None) -> bool:
"""Org-wide staff: seeded admin roles, a custom Admin role, or requisitions.manage.
Managers keep a created_by-scoped requisition list. Admins see every
non-deleted requisition, linked to a job post or not.
"""
user = current_user or {}
name = (user.get("role_name") or "").strip().lower()
if name in _ADMIN_ROLES:
return True
granted = user.get("permissions") or []
return PermissionTag.REQUISITIONS_MANAGE.value in granted
def has_permission( def has_permission(
granted: set[str] | list[str] | tuple[str, ...], granted: set[str] | list[str] | tuple[str, ...],
*required: PermissionTag, *required: PermissionTag,

View File

@ -37,7 +37,7 @@ server {
} }
# API-only prefixes (no SPA page at the bare path). # API-only prefixes (no SPA page at the bare path).
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3)(/|$) { location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms)(/|$) {
proxy_pass http://backend-api:8000; proxy_pass http://backend-api:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@ -57,18 +57,39 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Hashed filenames, so they can be cached hard. # Hashed filenames, so they can be cached hard. A miss after a rebuild is a
# stale tab (old import() hash) 404 + immutable would pin that miss for a
# year, so JS falls through to a one-shot reload of index.html.
location /assets/ { location /assets/ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always; add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always; add_header Referrer-Policy strict-origin-when-cross-origin always;
location ~ \.js$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
try_files $uri @stale_js;
}
}
location @stale_js {
default_type application/javascript;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options nosniff always;
return 200 "try{if(!sessionStorage.getItem('tf-chunk-reload')){sessionStorage.setItem('tf-chunk-reload','1');location.reload();}else{sessionStorage.removeItem('tf-chunk-reload');}}catch(e){location.reload();}";
} }
# index.html must never be cached, or a redeploy keeps serving the old asset hashes. # index.html must never be cached, or a redeploy keeps serving the old asset hashes.
location = /index.html { location = /index.html {
add_header Cache-Control "no-store"; etag off;
if_modified_since off;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
add_header Pragma "no-cache" always;
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always; add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always; add_header Referrer-Policy strict-origin-when-cross-origin always;

View File

@ -12,7 +12,7 @@ import Signup from './pages/Signup'
import ForgotPassword from './pages/ForgotPassword' import ForgotPassword from './pages/ForgotPassword'
import ConfirmEmail from './pages/ConfirmEmail' 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. // first paint pay for every screen a user never opens.
const SCREENS = { const SCREENS = {
dashboard: lazy(() => import('./screens/Dashboard')), dashboard: lazy(() => import('./screens/Dashboard')),
@ -22,6 +22,7 @@ const SCREENS = {
candidates: lazy(() => import('./screens/Candidates')), candidates: lazy(() => import('./screens/Candidates')),
talentpool: lazy(() => import('./screens/TalentPool')), talentpool: lazy(() => import('./screens/TalentPool')),
pipeline: lazy(() => import('./screens/Pipeline')), pipeline: lazy(() => import('./screens/Pipeline')),
progress: lazy(() => import('./screens/Progress')),
import: lazy(() => import('./screens/CvImport')), import: lazy(() => import('./screens/CvImport')),
jobboard: lazy(() => import('./screens/JobBoard')), jobboard: lazy(() => import('./screens/JobBoard')),
recruiterhub: lazy(() => import('./screens/RecruiterHub')), recruiterhub: lazy(() => import('./screens/RecruiterHub')),
@ -29,6 +30,7 @@ const SCREENS = {
tasks: lazy(() => import('./screens/Tasks')), tasks: lazy(() => import('./screens/Tasks')),
aiassistant: lazy(() => import('./screens/AiAssistant')), aiassistant: lazy(() => import('./screens/AiAssistant')),
interviews: lazy(() => import('./screens/Interviews')), interviews: lazy(() => import('./screens/Interviews')),
requisitions: lazy(() => import('./screens/Requisitions')),
assessments: lazy(() => import('./screens/Assessments')), assessments: lazy(() => import('./screens/Assessments')),
offers: lazy(() => import('./screens/Offers')), offers: lazy(() => import('./screens/Offers')),
managers: lazy(() => import('./screens/Managers')), managers: lazy(() => import('./screens/Managers')),

View File

@ -37,6 +37,7 @@ import Talent from '../screens/Talent'
import Tasks from '../screens/Tasks' import Tasks from '../screens/Tasks'
import AiAssistant from '../screens/AiAssistant' import AiAssistant from '../screens/AiAssistant'
import Interviews from '../screens/Interviews' import Interviews from '../screens/Interviews'
import Requisitions from '../screens/Requisitions'
import Assessments from '../screens/Assessments' import Assessments from '../screens/Assessments'
import Offers from '../screens/Offers' import Offers from '../screens/Offers'
import Managers from '../screens/Managers' import Managers from '../screens/Managers'
@ -53,7 +54,7 @@ const SCREENS = {
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates, dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard, talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard,
recruiterhub: RecruiterHub, talent: Talent, tasks: Tasks, aiassistant: AiAssistant, recruiterhub: RecruiterHub, talent: Talent, tasks: Tasks, aiassistant: AiAssistant,
interviews: Interviews, assessments: Assessments, offers: Offers, interviews: Interviews, requisitions: Requisitions, assessments: Assessments, offers: Offers,
managers: Managers, calendar: Calendar, reports: Reports, analytics: Analytics, managers: Managers, calendar: Calendar, reports: Reports, analytics: Analytics,
aistudio: AiStudio, notifications: Notifications, rbac: Rbac, aistudio: AiStudio, notifications: Notifications, rbac: Rbac,
settings: Settings, help: Help, settings: Settings, help: Help,

View File

@ -259,6 +259,15 @@ export function getByUserId(userId) {
return request('/candidate/fetch', { params: { user_id: userId } }) return request('/candidate/fetch', { params: { user_id: userId } })
} }
/**
* Candidates allocated to jobs this hiring manager owns requisitions they
* created (or are assigned on) linked job posts applications.
* Needs candidates.view. Server-scoped; recruiters should not use this.
*/
export function listForManager({ limit = 50, offset = 0 } = {}) {
return request('/candidate/manager/fetch', { params: { limit, offset } })
}
/** `data` is a list on the list path and a bare object on the by-id path. */ /** `data` is a list on the list path and a bare object on the by-id path. */
export function toRows(res) { export function toRows(res) {
if (Array.isArray(res?.data)) return res.data if (Array.isArray(res?.data)) return res.data

View File

@ -0,0 +1,52 @@
/* ============================================================
jobStats.js per-job pipeline stage counts (GET /job/stats/fetch).
Live aggregation over inbox + manual upload + unpromoted sheet rows,
deduped by email. Needs jobs.view or pipeline.view.
============================================================ */
import { request } from '../lib/apiClient'
import { REQUISITION_STATUSES } from './jobs'
const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label]))
/**
* List or single-job stage counts.
* Omit jobPostId for a paged list; pass jobPostId for one object.
*/
export function list({ jobPostId, search, ids, top, skip, activeOnly } = {}) {
return request('/job/stats/fetch', {
params: {
job_post_id: jobPostId,
search,
ids: Array.isArray(ids) ? ids.join(',') : ids,
top,
skip,
active_only: activeOnly,
},
})
}
/** API row -> what Progress cards and the table render. */
export function toJobStatsView(row) {
return {
id: row.job_post_id,
title: row.title || 'Untitled role',
department: row.department || null,
location: row.location || null,
status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status ?? '—',
requisitionStatus: row.requisition_status,
recruiterId: row.current_recruiter_id || null,
recruiterName: row.recruiter_name || null,
total: Number(row.total_applicants) || 0,
shortlist: Number(row.shortlisting) || 0,
screened: Number(row.screened) || 0,
assessment: Number(row.assessment) || 0,
interviewed: Number(row.interviewed) || 0,
offered: Number(row.offered) || 0,
onHold: Number(row.on_hold) || 0,
rejected: Number(row.rejected) || 0,
approved: Number(row.approved) || 0,
hired: Number(row.hired) || 0,
}
}

View File

@ -91,6 +91,12 @@ export function toJobView(row) {
skills: row.requirements ?? [], skills: row.requirements ?? [],
optionalSkills: row.optional_skills ?? [], optionalSkills: row.optional_skills ?? [],
description: row.description, description: row.description,
requisitionId: row.requisition_id || null,
requisitionTitle: row.requisition_title || '',
requisitionDepartment: row.requisition_department || '',
requisitionLabel: row.requisition_id
? `${(row.requisition_title || 'Untitled').trim() || 'Untitled'} - ${(row.requisition_department || '—').trim() || '—'}`
: null,
} }
} }

View File

@ -0,0 +1,64 @@
import { request } from '../lib/apiClient'
/* ============================================================
requisitions.js backend/candidate_forms/app.py requisition routes.
Standalone Annexure A table (app.requisitions), not candidate_forms.
created_by comes from the JWT on the server do not send it in the body.
============================================================ */
export const EMPLOYMENT_TYPES = [
{ value: 'permanent', label: 'Permanent' },
{ value: 'contract', label: 'Contract' },
{ value: 'temporary', label: 'Temporary' },
{ value: 'internee', label: 'Internee' },
]
export const EMPLOYMENT_TYPE_LABEL = Object.fromEntries(
EMPLOYMENT_TYPES.map((t) => [t.value, t.label]),
)
export function list() {
return request('/forms/requisition/fetch')
}
export function getById(formId) {
return request('/forms/requisition/fetch', { params: { form_id: formId } })
}
/** Searchable picker GET /forms/requisition/search. `q` matches title or department.
* `jobPostId` keeps the job's current requisition in the list while editing. */
export function search({ q, top, jobPostId } = {}) {
return request('/forms/requisition/search', {
params: {
q: q || undefined,
top,
job_post_id: jobPostId || undefined,
},
})
}
export function create(body) {
return request('/forms/requisition/create', { method: 'POST', body })
}
export function update(formId, body) {
return request('/forms/requisition/update', {
method: 'PATCH',
params: { form_id: formId },
body,
})
}
export function toRows(res) {
const data = res?.data
if (Array.isArray(data)) return data
if (data) return [data]
return []
}
export function approvalStatus(row) {
if (row?.approved_by_svp) return { key: 'approved', label: 'Approved' }
if (row?.approved_by_hr || row?.approved_by_vp) return { key: 'review', label: 'In review' }
return { key: 'open', label: 'Open' }
}

View File

@ -1,15 +1,20 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { NAV_GROUPS, ROUTES } from './routes' import { NAV_GROUPS, ROUTES } from './routes'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { HIRING_MANAGER_NAV, isHiringManager } from '../auth/permissions'
import Icon from '../ui/icons' import Icon from '../ui/icons'
import { BrandGlyph } from '../components/BrandMark' import { BrandGlyph } from '../components/BrandMark'
export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badges }) { export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badges }) {
const { can } = useAuth() const { can, user } = useAuth()
// A group heading renders only if something under it survived the permission // A group heading renders only if something under it survived the permission
// filter otherwise a low-privilege user sees orphaned section labels. // filter otherwise a low-privilege user sees orphaned section labels.
const visible = ROUTES.filter((r) => can(r.permission)) const visible = ROUTES.filter((r) => {
if (!can(r.permission)) return false
if (isHiringManager(user) && !HIRING_MANAGER_NAV.has(r.path)) return false
return true
})
return ( return (
<aside <aside

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 This is the one artefact ADR 0013 says to preserve outright: the module
breakdown, nav grouping and screen inventory are a validated UX artefact breakdown, nav grouping and screen inventory are a validated UX artefact
@ -23,6 +23,7 @@ export const ROUTES = [
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' }, { path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' }, { path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },
{ path: 'pipeline', title: 'Pipeline', icon: 'pipeline', group: 'Workspace', permission: 'pipeline.view' }, { path: 'pipeline', title: 'Pipeline', icon: 'pipeline', group: 'Workspace', permission: 'pipeline.view' },
{ path: 'progress', title: 'Progress', icon: 'trending-up', group: 'Workspace', permission: 'jobs.view' },
// --- Recruiting --- // --- Recruiting ---
{ path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' }, { path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' },
@ -34,6 +35,7 @@ export const ROUTES = [
// --- Hiring --- // --- Hiring ---
{ path: 'interviews', title: 'Interviews', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' }, { 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: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' }, { path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' },
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' }, { path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' },

View File

@ -16,7 +16,7 @@
export const MODULES = [ export const MODULES = [
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', 'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks',
'talent', 'talent', 'requisitions',
] ]
export const ACTIONS = [ export const ACTIONS = [
@ -34,3 +34,17 @@ export function makeCan(permissions) {
const set = new Set(permissions ?? []) const set = new Set(permissions ?? [])
return (tag) => !tag || set.has(tag) return (tag) => !tag || set.has(tag)
} }
export const HIRING_MANAGER_ROLE = 'hiring_manager'
/** Sidebar paths a manager-type role may see. Talent Pool / Matching / Import
also sit on candidates.view/create, so they are excluded here. */
export const HIRING_MANAGER_NAV = new Set([
'candidates', 'requisitions', 'interviews', 'calendar',
'help', 'aiassistant', 'aistudio', 'notifications',
])
export function isHiringManager(user) {
const name = (user?.role_name || '').trim().toLowerCase()
return name === HIRING_MANAGER_ROLE || name === 'manager'
}

View File

@ -81,6 +81,7 @@ export const qk = {
list: (p = {}) => ['jobs', 'list', p], list: (p = {}) => ['jobs', 'list', p],
requisitionStatuses: () => ['jobs', 'requisition-statuses'], requisitionStatuses: () => ['jobs', 'requisition-statuses'],
statusHistory: (id) => ['jobs', 'status-history', id], statusHistory: (id) => ['jobs', 'status-history', id],
stats: (p = {}) => ['jobs', 'stats', p],
}, },
talent: { talent: {
all: () => ['talent'], all: () => ['talent'],
@ -93,6 +94,7 @@ export const qk = {
candidates: { candidates: {
all: () => ['candidates'], all: () => ['candidates'],
list: (p = {}) => ['candidates', 'list', p], list: (p = {}) => ['candidates', 'list', p],
managerList: (p = {}) => ['candidates', 'manager', p],
count: (p = {}) => ['candidates', 'count', p], count: (p = {}) => ['candidates', 'count', p],
detail: (id) => ['candidates', 'detail', id], detail: (id) => ['candidates', 'detail', id],
history: (id, p = {}) => ['candidates', 'history', id, p], history: (id, p = {}) => ['candidates', 'history', id, p],
@ -125,6 +127,12 @@ export const qk = {
list: (p = {}) => ['forms', 'list', p], list: (p = {}) => ['forms', 'list', p],
definitions: () => ['forms', 'definitions'], definitions: () => ['forms', 'definitions'],
}, },
requisitions: {
all: () => ['requisitions'],
list: () => ['requisitions', 'list'],
detail: (id) => ['requisitions', 'detail', id],
search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
},
interviews: { interviews: {
all: () => ['interviews'], all: () => ['interviews'],
range: (p = {}) => ['interviews', 'range', p], range: (p = {}) => ['interviews', 'range', p],

View File

@ -5,6 +5,7 @@ import Alert from '../components/Alert'
import Spinner from '../components/Spinner' import Spinner from '../components/Spinner'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
export default function Login() { export default function Login() {
const form = useFormState({ email: '', password: '' }) const form = useFormState({ email: '', password: '' })
@ -28,9 +29,12 @@ export default function Login() {
form.setAlert(null) form.setAlert(null)
form.setBusy(true) form.setBusy(true)
try { try {
await signIn(form.values.email.trim(), form.values.password) const res = await signIn(form.values.email.trim(), form.values.password)
// In-SPA now: the old full page load out to /index.html#dashboard is gone. const role = res?.data?.role_name
navigate(from, { replace: true }) const dest = (from === '/dashboard' || from === '/') && isHiringManager({ role_name: role })
? '/candidates'
: from
navigate(dest, { replace: true })
} catch (err) { } catch (err) {
form.setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not sign in.') }) form.setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not sign in.') })
form.setBusy(false) form.setBusy(false)

View File

@ -1,6 +1,7 @@
/* The Forms tab of the candidate profile modal the digitized paper annexures: /* The Forms tab of the candidate profile modal Interview Analysis + Cultural
Employee Requisition (Annexure A), Interview Analysis + Cultural Fit (the two Fit (the two halves of Annexure E), and the Offer (Annexure J fields on the
halves of Annexure E), and the Offer (Annexure J fields on the offers table). 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 Field and criterion labels are rendered from GET /forms/definitions the
backend is the single authority for the paper forms' exact wording. The backend is the single authority for the paper forms' exact wording. The
@ -12,9 +13,8 @@
friendly version. Forms attach to an application the inbox row for email friendly version. Forms attach to an application the inbox row for email
applicants, the manual_upload_candidate row for hand-added candidates. applicants, the manual_upload_candidate row for hand-added candidates.
Layout system: .hf-* classes in styles.css. Rated criteria render as the Layout system: .hf-* classes in styles.css. Rated criteria render as a
paper's own table (scale header, radio-dot cells, the SECTION AVERAGE foot); 25/50/75/100% grid; section and combined totals are percentages. */
the score summary is a stat-tile row with the combined overall as the hero. */
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@ -22,12 +22,29 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as formsApi from '../api/forms' import * as formsApi from '../api/forms'
import * as offersApi from '../api/offers' import * as offersApi from '../api/offers'
import { STAGE_FROM_STATUS } from '../api/pipeline' import { STAGE_FROM_STATUS } from '../api/pipeline'
const RATING_POINTS = [25, 50, 75, 100]
/** Criterion ticks and averages are 0100. Legacy 14 values convert once. */
function toPercent(score) {
if (score == null || score === '') return null
const n = Number(score)
if (!Number.isFinite(n)) return null
if (n > 0 && n <= 4) return n * 25
return Math.round(n * 100) / 100
}
function formatPercent(score) {
const pct = toPercent(score)
return pct == null ? null : `${pct}%`
}
const WORK_LOCATIONS = ['Maymar Office', 'Head Office'] const WORK_LOCATIONS = ['Maymar Office', 'Head Office']
const WORK_TIMINGS = ['Morning', 'Afternoon', 'Evening', 'Night'] const WORK_TIMINGS = ['Morning', 'Afternoon', 'Evening', 'Night']
@ -60,9 +77,10 @@ function useFormsWrite({ userId, mutationFn, success, onDone }) {
} }
export default function CandidateFormsTab({ userId, live }) { export default function CandidateFormsTab({ userId, live }) {
const { can } = useAuth() const { can, user } = useAuth()
const isManager = isHiringManager(user)
// Open on the process's first step; the switcher order IS the paper sequence. // 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 // Forms attach to an application: an inbox row for email applicants, or the
// manual_upload_candidate row for hand-added / sourced candidates. Exactly // manual_upload_candidate row for hand-added / sourced candidates. Exactly
@ -81,7 +99,7 @@ export default function CandidateFormsTab({ userId, live }) {
queryKey: qk.forms.definitions(), queryKey: qk.forms.definitions(),
queryFn: formsApi.definitions, queryFn: formsApi.definitions,
enabled: hasApplication && unlocked, enabled: hasApplication && unlocked,
staleTime: Infinity, staleTime: 0,
}) })
const formsQuery = useQuery({ const formsQuery = useQuery({
queryKey: qk.forms.list(listParams), queryKey: qk.forms.list(listParams),
@ -92,7 +110,7 @@ export default function CandidateFormsTab({ userId, live }) {
const offersQuery = useQuery({ const offersQuery = useQuery({
queryKey: qk.offers.list({ inboxId }), queryKey: qk.offers.list({ inboxId }),
queryFn: () => offersApi.list({ inboxId }), queryFn: () => offersApi.list({ inboxId }),
enabled: Boolean(inboxId) && unlocked, enabled: Boolean(inboxId) && unlocked && !isManager,
}) })
if (!hasApplication) { if (!hasApplication) {
@ -107,7 +125,7 @@ export default function CandidateFormsTab({ userId, live }) {
<EmptyState icon="lock" title="Forms unlock at the Interview stage"> <EmptyState icon="lock" title="Forms unlock at the Interview stage">
This candidate is at {STAGE_FROM_STATUS[stage] ?? titleCase(stage)} with no interview 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 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> </EmptyState>
) )
} }
@ -124,24 +142,32 @@ export default function CandidateFormsTab({ userId, live }) {
const defs = defsQuery.data?.data const defs = defsQuery.data?.data
const rows = formsQuery.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 summary = formsQuery.data?.summary ?? null
const offers = offersQuery.data?.data ?? [] const offers = offersQuery.data?.data ?? []
// Spread into create payloads exactly one key, matching the backend XOR. // Spread into create payloads exactly one key, matching the backend XOR.
const link = inboxId const link = {
? { inbox_id: Number(inboxId) } ...(inboxId
: { manual_upload_candidate_id: manualId } ? { inbox_id: Number(inboxId) }
: { manual_upload_candidate_id: manualId }),
...(live?.assigned_job_post_id ? { job_post_id: live.assigned_job_post_id } : {}),
}
const done = { const done = {
requisition: rows.some((r) => r.form_type === 'requisition'),
interview_analysis: rows.some((r) => r.form_type === 'interview_analysis'), interview_analysis: rows.some((r) => r.form_type === 'interview_analysis'),
cultural_fit: rows.some((r) => r.form_type === 'cultural_fit'), cultural_fit: rows.some((r) => r.form_type === 'cultural_fit'),
offer: offers.length > 0, offer: offers.length > 0,
} }
const segTabs = [ const segTabs = [
{ key: 'requisition', label: 'Requisition' },
{ key: 'interview_analysis', label: 'Interview Analysis' }, { key: 'interview_analysis', label: 'Interview Analysis' },
{ key: 'cultural_fit', label: 'Cultural Fit' }, { key: 'cultural_fit', label: 'Cultural Fit' },
{ key: 'offer', label: 'Offer' }, ...(!isManager ? [{ key: 'offer', label: 'Offer' }] : []),
] ]
const evalCount = rows.filter( const evalCount = rows.filter(
@ -165,21 +191,6 @@ export default function CandidateFormsTab({ userId, live }) {
))} ))}
</div> </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') && ( {(seg === 'interview_analysis' || seg === 'cultural_fit') && (
<RatedEvaluationForm <RatedEvaluationForm
key={seg} key={seg}
@ -194,7 +205,7 @@ export default function CandidateFormsTab({ userId, live }) {
canEdit={can('interviews.edit')} canEdit={can('interviews.edit')}
/> />
)} )}
{seg === 'offer' && ( {seg === 'offer' && !isManager && (
<OfferSection userId={userId} inboxId={inboxId} live={live} offersQuery={offersQuery} /> <OfferSection userId={userId} inboxId={inboxId} live={live} offersQuery={offersQuery} />
)} )}
</> </>
@ -203,19 +214,18 @@ export default function CandidateFormsTab({ userId, live }) {
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
Annexure E's OVERALL SCORE SUMMARY three section tiles plus the combined Annexure E's OVERALL SCORE SUMMARY three section tiles plus the combined
overall as the hero. Values are magnitudes on a fixed 14 scale, so each overall as the hero. Ticks and averages are 25/50/75/100 percentages. */
tile carries a thin single-hue meter; numbers stay in text ink. */
function ScoreTile({ label, value, hero, sub }) { function ScoreTile({ label, value, hero, sub }) {
const pct = value != null ? Math.max(0, Math.min(100, (value / 4) * 100)) : 0 const pct = toPercent(value)
return ( return (
<div className={`hf-tile${hero ? ' hero' : ''}`}> <div className={`hf-tile${hero ? ' hero' : ''}`}>
<div className="hf-k" title={label}>{label}</div> <div className="hf-k" title={label}>{label}</div>
<div className="hf-v"> <div className="hf-v">
{value != null ? value : '—'} {pct != null ? pct : '—'}
{value != null && <small>/ 4</small>} {pct != null && <small>%</small>}
</div> </div>
<div className="hf-meter"><i style={{ width: `${pct}%` }} /></div> <div className="hf-meter"><i style={{ width: `${pct ?? 0}%` }} /></div>
{sub && <div className="hf-sub">{sub}</div>} {sub && <div className="hf-sub">{sub}</div>}
</div> </div>
) )
@ -252,7 +262,7 @@ function fieldLabel(def, key) {
} }
function sectionAverage(ratings) { function sectionAverage(ratings) {
const values = Object.values(ratings).filter((v) => v != null) const values = Object.values(ratings).map(toPercent).filter((v) => v != null)
if (!values.length) return null if (!values.length) return null
return Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100 return Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100
} }
@ -271,7 +281,7 @@ function FormRowList({ rows, defs, onEdit, canEdit }) {
</div> </div>
</div> </div>
<div className="lr-right" style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div className="lr-right" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{r.overall_score != null && <Badge>{r.overall_score} / 4</Badge>} {r.overall_score != null && <Badge>{formatPercent(r.overall_score)}</Badge>}
{r.recommendation && ( {r.recommendation && (
<Badge className="b-gray"> <Badge className="b-gray">
{defs.recommendation_labels[r.recommendation] ?? r.recommendation} {defs.recommendation_labels[r.recommendation] ?? r.recommendation}
@ -292,26 +302,29 @@ function FormRowList({ rows, defs, onEdit, canEdit }) {
/* The paper's rating grid: scale header, one radio-dot per cell, average foot. */ /* The paper's rating grid: scale header, one radio-dot per cell, average foot. */
function RatingTable({ section, defs, ratings, onRate }) { function RatingTable({ section, defs, ratings, onRate }) {
const average = sectionAverage(ratings) const average = sectionAverage(ratings)
const points = Array.isArray(defs.rating_points) && defs.rating_points.length
? defs.rating_points
: RATING_POINTS
return ( return (
<div className="hf-rate"> <div className="hf-rate">
<div className="hf-rate-head"> <div className="hf-rate-head">
<div>Criteria</div> <div>Criteria</div>
{[1, 2, 3, 4].map((n) => ( {points.map((n) => (
<div key={n}> <div key={n}>
<span className="hf-scale-full">{defs.rating_labels[String(n)]}</span> <span className="hf-scale-full">{defs.rating_labels[String(n)] ?? formatPercent(n)}</span>
<span className="hf-scale-short" title={defs.rating_labels[String(n)]}>{n}</span> <span className="hf-scale-short" title={defs.rating_labels[String(n)]}>{formatPercent(n)}</span>
</div> </div>
))} ))}
</div> </div>
{section.criteria.map((c) => ( {section.criteria.map((c) => (
<div className="hf-rate-row" key={c.key}> <div className="hf-rate-row" key={c.key}>
<div>{c.label}</div> <div>{c.label}</div>
{[1, 2, 3, 4].map((n) => ( {points.map((n) => (
<div className="hf-rate-cell" key={n}> <div className="hf-rate-cell" key={n}>
<button <button
type="button" type="button"
className={`hf-dot${ratings[c.key] === n ? ' on' : ''}`} className={`hf-dot${Number(ratings[c.key]) === Number(n) ? ' on' : ''}`}
aria-label={`${c.label}: ${defs.rating_labels[String(n)]}`} aria-label={`${c.label}: ${defs.rating_labels[String(n)] ?? formatPercent(n)}`}
onClick={() => onRate(c.key, n)} onClick={() => onRate(c.key, n)}
/> />
</div> </div>
@ -320,7 +333,7 @@ function RatingTable({ section, defs, ratings, onRate }) {
))} ))}
<div className="hf-rate-foot"> <div className="hf-rate-foot">
<div>{section.average_label || 'Section average'}</div> <div>{section.average_label || 'Section average'}</div>
<div className="hf-avg">{average ?? '—'}</div> <div className="hf-avg">{formatPercent(average) ?? '—'}</div>
</div> </div>
</div> </div>
) )
@ -359,7 +372,7 @@ function RatedEvaluationForm({ formType, def, defs, rows, userId, link, live, ca
} }
for (const s of editing.sections ?? []) { for (const s of editing.sections ?? []) {
for (const c of s.criteria ?? []) { for (const c of s.criteria ?? []) {
if (ratings[s.key] && c.key in ratings[s.key]) ratings[s.key][c.key] = c.rating ?? null if (ratings[s.key] && c.key in ratings[s.key]) ratings[s.key][c.key] = toPercent(c.rating)
} }
} }
return { return {
@ -422,7 +435,10 @@ function EvaluationEditor({ formType, def, defs, row, initial, userId, link, liv
const setRating = (sectionKey, critKey, value) => const setRating = (sectionKey, critKey, value) =>
setRatings((r) => ({ setRatings((r) => ({
...r, ...r,
[sectionKey]: { ...r[sectionKey], [critKey]: r[sectionKey][critKey] === value ? null : value }, [sectionKey]: {
...r[sectionKey],
[critKey]: Number(r[sectionKey][critKey]) === Number(value) ? null : value,
},
})) }))
const save = useFormsWrite({ const save = useFormsWrite({
@ -570,270 +586,6 @@ function EvaluationEditor({ formType, def, defs, row, initial, userId, link, liv
) )
} }
/* ------------------------------------------------------------------
Annexure A Employee Requisition. One form per application (the latest row
is loaded for amendment); the approval chain is typed name + date, not a
workflow engine. */
const SIGN_SLOTS = [
{ nameKey: 'initiated_by', dateKey: 'initiated_date', role: 'Initiated By' },
{ nameKey: 'recommended_by', dateKey: 'recommended_date', role: 'Recommended By · Director' },
{ nameKey: 'approved_by', dateKey: 'approved_date', role: 'Approved By · Director HR' },
{ nameKey: 'vp_approved_by', dateKey: 'vp_approved_date', role: 'Approved By · VP/SVP' },
]
function RequisitionForm({ def, defs, rows, userId, link, live, canCreate, canEdit }) {
const row = rows[0] ?? null
const allowed = row ? canEdit : canCreate
const initial = useMemo(() => {
const fields = {}
for (const f of def.fields) {
const saved = row?.fields?.[f.key]
if (f.kind === 'bool') fields[f.key] = saved === true ? 'yes' : saved === false ? 'no' : ''
else fields[f.key] = saved != null ? String(saved) : ''
}
if (!fields.job_title) fields.job_title = row ? '' : live?.job_title || ''
return { fields, date: toDateInput(row?.form_date) || toDateInput(new Date().toISOString()) }
}, [def, row, live])
const [fields, setFields] = useState(initial.fields)
const [date, setDate] = useState(initial.date)
const [errors, setErrors] = useState({})
const set = (k, v) => setFields((f) => ({ ...f, [k]: v }))
const save = useFormsWrite({
userId,
mutationFn: () => {
const payload = {}
for (const f of def.fields) {
const value = fields[f.key]
if (f.kind === 'bool') payload[f.key] = value === '' ? null : value === 'yes'
else payload[f.key] = value === '' ? null : value
}
const body = {
form_date: date ? new Date(`${date}T00:00`).toISOString() : null,
fields: payload,
}
if (row) return formsApi.update(row.id, body)
return formsApi.create({ form_type: 'requisition', ...link, ...body })
},
success: row ? 'Requisition form updated' : 'Requisition form saved',
})
function submit() {
const next = {}
if (!fields.job_title.trim()) next.job_title = 'Enter the job title'
if (fields.jd_available === 'no') {
next.jd_available = 'JD is mandatory — the TA team will not proceed without it'
}
setErrors(next)
if (Object.keys(next).length) return
save.mutate()
}
const notPermanent = fields.employment_type && fields.employment_type !== 'permanent'
const label = (key) => fieldLabel(def, key)
return (
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="hf-block">
<div className="hf-block-title">Position Request</div>
<div className="hf-note">{def.header_note}</div>
<div className="form-grid">
<div className="form-field">
<label>{label('department')}</label>
<input value={fields.department} onChange={(e) => set('department', e.target.value)} />
</div>
<div className="form-field">
<label>{label('job_title')} <span className="req">*</span></label>
<input
className={errors.job_title ? 'err' : ''}
value={fields.job_title}
onChange={(e) => set('job_title', e.target.value)}
/>
<FieldError>{errors.job_title}</FieldError>
</div>
<div className="form-field">
<label>Date</label>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="form-field">
<label>{label('date_needed')}</label>
<input type="date" value={fields.date_needed} onChange={(e) => set('date_needed', e.target.value)} />
</div>
<div className="form-field">
<label>{label('employment_type')}</label>
<select value={fields.employment_type} onChange={(e) => set('employment_type', e.target.value)}>
<option value=""></option>
{defs.employment_types.map((t) => (
<option key={t} value={t}>{defs.employment_type_labels[t]}</option>
))}
</select>
</div>
<div className="form-field">
<label>{label('jd_available')}</label>
<select
className={errors.jd_available ? 'err' : ''}
value={fields.jd_available}
onChange={(e) => set('jd_available', e.target.value)}
>
<option value=""></option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
<FieldError>{errors.jd_available}</FieldError>
</div>
{notPermanent && (
<>
<div className="form-field">
<label>{label('period_from')}</label>
<input type="date" value={fields.period_from} onChange={(e) => set('period_from', e.target.value)} />
</div>
<div className="form-field">
<label>{label('period_to')}</label>
<input type="date" value={fields.period_to} onChange={(e) => set('period_to', e.target.value)} />
</div>
</>
)}
</div>
</div>
<div className="hf-block">
<div className="hf-block-title">
<label>
<input
type="checkbox"
checked={fields.is_replacement === 'yes'}
onChange={(e) => set('is_replacement', e.target.checked ? 'yes' : '')}
/>
{label('is_replacement')}
</label>
</div>
{fields.is_replacement === 'yes' && (
<div className="form-grid">
<div className="form-field">
<label>{label('replacement_employee')}</label>
<input
value={fields.replacement_employee}
onChange={(e) => set('replacement_employee', e.target.value)}
/>
</div>
<div className="form-field">
<label>{label('replacement_grade')}</label>
<input
value={fields.replacement_grade}
onChange={(e) => set('replacement_grade', e.target.value)}
/>
</div>
<div className="form-field">
<label>{label('replacement_job_title')}</label>
<input
value={fields.replacement_job_title}
onChange={(e) => set('replacement_job_title', e.target.value)}
/>
</div>
<div className="form-field">
<label>{label('replacement_date_separated')}</label>
<input
type="date"
value={fields.replacement_date_separated}
onChange={(e) => set('replacement_date_separated', e.target.value)}
/>
</div>
</div>
)}
</div>
<div className="hf-block">
<div className="hf-block-title">New / Additional Headcount</div>
<div className="form-grid">
<div className="form-field col-span-2">
<label>{label('headcount_justification')}</label>
<textarea
rows={2}
style={{ minHeight: 56 }}
value={fields.headcount_justification}
onChange={(e) => set('headcount_justification', e.target.value)}
/>
</div>
<div className="form-field">
<label>{label('proposed_budget')}</label>
<input value={fields.proposed_budget} onChange={(e) => set('proposed_budget', e.target.value)} />
</div>
<div className="form-field">
<label>{label('recommended_grade')}</label>
<input value={fields.recommended_grade} onChange={(e) => set('recommended_grade', e.target.value)} />
</div>
</div>
</div>
<div className="hf-block">
<div className="hf-block-title">
<label>
<input
type="checkbox"
checked={fields.internal_recommendation === 'yes'}
onChange={(e) => set('internal_recommendation', e.target.checked ? 'yes' : '')}
/>
{label('internal_recommendation')}
</label>
</div>
{fields.internal_recommendation === 'yes' && (
<div className="form-grid">
<div className="form-field">
<label>{label('recommended_employee_name')}</label>
<input
value={fields.recommended_employee_name}
onChange={(e) => set('recommended_employee_name', e.target.value)}
/>
</div>
<div className="form-field">
<label>{label('recommended_employee_department')}</label>
<input
value={fields.recommended_employee_department}
onChange={(e) => set('recommended_employee_department', e.target.value)}
/>
</div>
</div>
)}
</div>
<div className="hf-block">
<div className="hf-block-title">Approvals</div>
<div className="hf-sign-grid">
{SIGN_SLOTS.map((slot) => (
<div key={slot.nameKey} className="hf-sign">
<span className="hf-sign-role">{slot.role}</span>
<input
placeholder="Name"
value={fields[slot.nameKey]}
onChange={(e) => set(slot.nameKey, e.target.value)}
/>
<input
type="date"
value={fields[slot.dateKey]}
onChange={(e) => set(slot.dateKey, e.target.value)}
/>
</div>
))}
</div>
</div>
<div className="flex items-center gap-8" style={{ marginTop: 14 }}>
<button className="btn btn-primary btn-sm" disabled={!allowed || save.isPending} type="submit">
{save.isPending ? 'Saving…' : row ? 'Save Changes' : 'Save Form'}
</button>
{row && (
<span className="text-muted" style={{ fontSize: 12 }}>
Filed {toDateInput(row.created_at)} by {row.created_by_name || 'unknown'}
</span>
)}
</div>
</form>
)
}
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
Annexure J the offer email's remuneration table, written onto the existing Annexure J the offer email's remuneration table, written onto the existing
offers record (drafted here, ISSUED from the Offers screen). */ offers record (drafted here, ISSUED from the Offers screen). */

View File

@ -1,4 +1,4 @@
/* The 9-tab candidate profile modal, split out of Candidates.jsx it was the /* The candidate profile modal, split out of Candidates.jsx it was the
single largest block in js/candidates.js and deserves its own file. single largest block in js/candidates.js and deserves its own file.
TWO DATA MODES, selected by whether the caller passes a `userId`: TWO DATA MODES, selected by whether the caller passes a `userId`:
@ -7,8 +7,8 @@
the prototype did. the prototype did.
LIVE (TalentPool.jsx) GET /candidate/fetch?user_id= switches the endpoint LIVE (TalentPool.jsx) GET /candidate/fetch?user_id= switches the endpoint
into detail mode and returns the real record: résumé text, the agent's into detail mode and returns the real record: résumé text, the agent's
match verdict, documents, and the four child collections (interviews, match verdict, documents, and the child collections (interviews,
notes, activity, feedback). The write tabs POST to their own endpoints notes, activity). The write tabs POST to their own endpoints
and invalidate this one query, so the whole modal repaints from a single and invalidate this one query, so the whole modal repaints from a single
refetch. History is fetched separately (GET /candidate/history/fetch) refetch. History is fetched separately (GET /candidate/history/fetch)
when that tab opens it is append-only and not part of the detail payload. when that tab opens it is append-only and not part of the detail payload.
@ -18,7 +18,7 @@
worse than showing none. worse than showing none.
Scoping differs between the child tables and is not interchangeable: notes Scoping differs between the child tables and is not interchangeable: notes
hang off the candidate (users.id), while interviews, activity and feedback hang off the candidate (users.id), while interviews and activity
hang off one application (inbox.id). */ hang off one application (inbox.id). */
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@ -30,6 +30,7 @@ import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { seedQuery } from '../data/seedQueries' import { seedQuery } from '../data/seedQueries'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
@ -41,12 +42,11 @@ import CandidateFormsTab from './CandidateForms'
import { companies, fmtDate, moneyK, pick } from '../data/seed' import { companies, fmtDate, moneyK, pick } from '../data/seed'
/* Workflow order: learn (Overview, Resume, Documents) interview (Interview, /* Workflow order: learn (Overview, Resume, Documents) interview (Interview,
Forms, Feedback) track (Notes, Activity) audit (Timeline, History). */ Forms) track (Notes, Activity) audit (Timeline, History). */
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History'] const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', 'Timeline', 'History']
// Forward progression for the live Advance button. Rejected has no next stage. // Forward progression for the live Advance button. Rejected has no next stage.
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'] const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 } const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round'] const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show'] const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note'] const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
@ -126,10 +126,11 @@ export default function CandidateProfile({
variant = 'modal', variant = 'modal',
}) { }) {
const { toast } = useToast() const { toast } = useToast()
const { can } = useAuth() const { can, user } = useAuth()
const [tab, setTab] = useState('Overview') const isManager = isHiringManager(user)
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
const [tab, setTab] = useState(isManager ? 'Forms' : 'Overview')
const { data: interviews = [] } = useQuery(seedQuery('interviews')) const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const isLive = Boolean(c.userId) const isLive = Boolean(c.userId)
const detail = useCandidateDetail(c.userId) const detail = useCandidateDetail(c.userId)
@ -226,11 +227,10 @@ export default function CandidateProfile({
const counts = live && { const counts = live && {
Interview: live.interviews?.length ?? 0, 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, Notes: live.notes?.length ?? 0,
Activity: live.activity?.length ?? 0, Activity: live.activity?.length ?? 0,
Documents: live.documents?.length ?? 0, Documents: live.documents?.length ?? 0,
Feedback: live.feedback?.length ?? 0,
} }
// In live mode nothing below the hero can be trusted until the detail payload // In live mode nothing below the hero can be trusted until the detail payload
@ -247,7 +247,7 @@ export default function CandidateProfile({
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState> <EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
) : null ) : null
const actions = ( const actions = isManager ? null : (
<> <>
<button <button
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`} className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
@ -338,7 +338,7 @@ export default function CandidateProfile({
value={tab} value={tab}
onChange={setTab} onChange={setTab}
className="tabs tabs-wrap" className="tabs tabs-wrap"
tabs={TABS.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))} tabs={visibleTabs.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
/> />
</div> </div>
@ -605,37 +605,6 @@ export default function CandidateProfile({
))} ))}
</div> </div>
)))} )))}
{tab === 'Feedback' && (guard || (live ? (
<FeedbackTab userId={c.userId} inboxId={inboxId} rows={live.feedback ?? []} />
) : (
<>
<div className="list-tight">
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => {
const r = recruiters[i]
if (!r) return null
const notes = [
'Excellent technical depth and clear communication.',
'Good problem solving, would benefit from more system design exposure.',
'Solid candidate, positive team energy.',
]
return (
<div className="list-row" key={score}>
<Avatar name={r.name} initials={r.initials} color={r.color} />
<div className="lr-main">
<div className="lr-title">{r.name}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{notes[i]}</div>
</div>
<div className="lr-right"><Badge>{score}</Badge></div>
</div>
)
})}
</div>
<button className="btn btn-primary btn-sm" style={{ marginTop: 14 }} onClick={() => toast('Scorecard form opened', 'info')}>
<Icon name="plus" /> Submit Scorecard
</button>
</>
)))}
</div> </div>
</> </>
) )
@ -650,7 +619,7 @@ export default function CandidateProfile({
<div className="cand-page-crumb"> <div className="cand-page-crumb">
Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong> Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
</div> </div>
<div className="cand-page-actions">{actions}</div> {actions && <div className="cand-page-actions">{actions}</div>}
</div> </div>
<div className="card"> <div className="card">
<div className="card-body">{body}</div> <div className="card-body">{body}</div>
@ -1241,198 +1210,3 @@ function DocumentsTab({ rows, inboxId, manualUploadCandidateId }) {
</div> </div>
) )
} }
/**
* One scorecard, revisable in place via PATCH /feedback/update.
*
* Same authorship rule as NoteRow: the route neither checks nor reassigns
* `reviewed_by`, so only the original reviewer is offered the control. A
* revision keeps their name on it, which is the point.
*/
function FeedbackRow({ row: f, userId }) {
const { user } = useAuth()
const { toast } = useToast()
const [editing, setEditing] = useState(false)
const [form, setForm] = useState({
review: f.review || REVIEWS[0],
score: f.score == null ? '' : String(f.score),
note: f.note || '',
})
const set = (k, v) => setForm((s) => ({ ...s, [k]: v }))
const mine = Boolean(user?.id && f.reviewed_by && String(user.id) === String(f.reviewed_by))
const save = useProfileWrite({
userId,
mutationFn: () => candidatesApi.updateFeedback(f.id, {
review: form.review,
score: form.score === '' ? 0 : Number(form.score),
note: form.note.trim(),
}),
success: 'Scorecard updated',
onDone: () => setEditing(false),
})
function submit() {
const score = form.score === '' ? 0 : Number(form.score)
if (!Number.isFinite(score) || score < 0 || score > 100) {
toast('Score must be between 0 and 100', 'warning')
return
}
save.mutate()
}
if (editing) {
return (
<div className="list-row" style={{ alignItems: 'flex-start' }}>
<Avatar name={f.reviewed_by_name || 'Unknown'} />
<div className="lr-main">
<div className="form-grid">
<div className="form-field">
<label>Recommendation</label>
<select value={form.review} onChange={(e) => set('review', e.target.value)}>
{REVIEWS.map((r) => <option key={r}>{r}</option>)}
</select>
</div>
<div className="form-field">
<label>Score (0100)</label>
<input
type="number" min="0" max="100"
value={form.score}
onChange={(e) => set('score', e.target.value)}
/>
</div>
</div>
<div className="form-field">
<label>Notes</label>
<textarea value={form.note} onChange={(e) => set('note', e.target.value)} rows={3} />
</div>
<div className="flex items-center gap-8">
<button className="btn btn-primary btn-sm" disabled={save.isPending} onClick={submit}>
{save.isPending ? 'Saving…' : 'Save'}
</button>
<button
className="btn btn-secondary btn-sm"
disabled={save.isPending}
onClick={() => {
setForm({
review: f.review || REVIEWS[0],
score: f.score == null ? '' : String(f.score),
note: f.note || '',
})
setEditing(false)
}}
>
Cancel
</button>
</div>
</div>
</div>
)
}
return (
<div className="list-row">
<Avatar name={f.reviewed_by_name || 'Unknown'} />
<div className="lr-main">
<div className="lr-title">{f.reviewed_by_name || 'Unknown reviewer'}</div>
{f.note && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{f.note}</div>}
<div className="lr-sub">
{fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}
{f.updated_at && f.updated_at !== f.created_at ? ' · revised' : ''}
</div>
</div>
<div className="lr-right" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{f.review ? <Badge>{f.review}</Badge> : null}
{mine && (
<button className="act-btn" data-tip="Revise scorecard" aria-label="Revise scorecard" onClick={() => setEditing(true)}>
<Icon name="edit" />
</button>
)}
</div>
</div>
)
}
function FeedbackTab({ userId, inboxId, rows }) {
const { toast } = useToast()
const [form, setForm] = useState({ review: REVIEWS[0], score: '', note: '' })
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const create = useProfileWrite({
userId,
mutationFn: () => candidatesApi.createFeedback({
inboxId,
review: form.review,
score: form.score === '' ? 0 : Number(form.score),
note: form.note.trim(),
}),
success: 'Scorecard submitted',
onDone: () => setForm({ review: REVIEWS[0], score: '', note: '' }),
})
function submit() {
const score = form.score === '' ? 0 : Number(form.score)
if (!Number.isFinite(score) || score < 0 || score > 100) {
toast('Score must be between 0 and 100', 'warning')
return
}
create.mutate()
}
return (
<>
{rows.length ? (
<div className="list-tight">
{rows.map((f) => <FeedbackRow key={f.id} row={f} userId={userId} />)}
</div>
) : (
<EmptyState icon="award" title="No scorecards yet">Be the first to review this candidate.</EmptyState>
)}
<div className="divider" />
<h3 className="form-section-title" style={{ marginTop: 0 }}>Submit a scorecard</h3>
<div className="form-grid">
<div className="form-field">
<label>Recommendation</label>
<select value={form.review} onChange={(e) => set('review', e.target.value)}>
{REVIEWS.map((r) => <option key={r}>{r}</option>)}
</select>
</div>
<div className="form-field">
<label>Score (0100)</label>
<input
type="number"
min="0"
max="100"
value={form.score}
onChange={(e) => set('score', e.target.value)}
placeholder="80"
/>
</div>
</div>
<div className="form-field">
<label>Notes</label>
<textarea
value={form.note}
onChange={(e) => set('note', e.target.value)}
placeholder="What stood out, and what would you probe next round?"
/>
</div>
<button
className="btn btn-primary btn-sm"
style={{ marginTop: 10 }}
disabled={!inboxId || create.isPending}
onClick={submit}
>
<Icon name="plus" /> {create.isPending ? 'Submitting…' : 'Submit Scorecard'}
</button>
{!inboxId && (
<p className="text-muted" style={{ marginTop: 8, fontSize: 12.5 }}>
Scorecards attach to an email application this candidate was added manually,
so submitting is unavailable here.
</p>
)}
</>
)
}

View File

@ -1,4 +1,4 @@
/* ============================================================ /* ============================================================
Candidates the scored-candidate pool, on live backend data. Candidates the scored-candidate pool, on live backend data.
Rows come from GET /candidate/fetch (all jobs) via the shared Rows come from GET /candidate/fetch (all jobs) via the shared
@ -14,10 +14,12 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable' import DataTable, { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives' import { Avatar, Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import CandidateProfile from './CandidateProfile' import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile' import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
@ -106,7 +108,134 @@ const REFERRAL_RE = new RegExp(
*/ */
const referralValue = (raw) => (raw || '').trim().toLowerCase() const referralValue = (raw) => (raw || '').trim().toLowerCase()
const STAGE_BADGE = {
Shortlist: 'b-indigo',
Screening: 'b-teal',
Assessment: 'b-purple',
Interview: 'b-amber',
Offer: 'b-green',
Approved: 'b-green',
Hired: 'b-green',
'On Hold': 'b-amber',
Rejected: 'b-gray',
}
export default function Candidates() { export default function Candidates() {
const { user } = useAuth()
if (isHiringManager(user)) return <HiringManagerCandidates />
return <RecruiterCandidates />
}
function HiringManagerCandidates() {
const navigate = useNavigate()
const location = useLocation()
const [q, setQ] = useState('')
useEffect(() => {
const id = location.state?.openCandidate
if (id) navigate(`/candidate/${id}`, { replace: true })
}, [location.state, navigate])
const listQuery = useQuery({
queryKey: qk.candidates.managerList(),
queryFn: async () => {
const res = await candidatesApi.listForManager({ limit: 200, offset: 0 })
return Array.isArray(res?.data) ? res.data : []
},
})
const rowsAll = listQuery.data ?? []
const rows = useMemo(() => {
if (!q.trim()) return rowsAll
const needle = q.trim().toLowerCase()
return rowsAll.filter((r) => {
const hay = [r.name, r.email, r.job_title].filter(Boolean).join(' ').toLowerCase()
return hay.includes(needle)
})
}, [rowsAll, q])
const columns = [
{
key: 'name',
label: 'Candidate',
sortable: true,
sortValue: (r) => r.name || '',
render: (r) => (
<>
<div className="cell-primary">{r.name || '—'}</div>
<div className="cell-sub">{r.email || '—'}</div>
</>
),
},
{
key: 'job',
label: 'Job',
sortable: true,
sortValue: (r) => r.job_title || '',
render: (r) => r.job_title || '—',
},
{
key: 'stage',
label: 'Stage',
sortable: true,
sortValue: (r) => r.application_status || '',
render: (r) => {
const status = String(r.application_status || '').toUpperCase()
const stage = pipelineApi.STAGE_FROM_STATUS[status] ?? 'Shortlist'
return <Badge className={STAGE_BADGE[stage] || ''}>{stage}</Badge>
},
},
{
key: 'applied',
label: 'Allocated',
sortable: true,
sortValue: (r) => r.created_at || '',
render: (r) => (
<span className="text-muted">
{r.created_at ? fmtDate(new Date(r.created_at)) : '—'}
</span>
),
},
]
return (
<div className="page">
<PageHeader
title="Candidates"
sub={`${rowsAll.length} candidate${rowsAll.length === 1 ? '' : 's'} on your requisition jobs`}
/>
<div className="card">
<div className="card-body">
<div className="toolbar" style={{ marginBottom: 14 }}>
<div className="toolbar-search" style={{ flex: 1, maxWidth: 360 }}>
<Icon name="search" />
<input
placeholder="Search name, email, or job…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
</div>
{listQuery.isPending && <SkeletonRows rows={4} />}
{listQuery.isError && (
<EmptyState icon="users" title="Couldnt load candidates">
{friendlyAuthError(listQuery.error, 'Please try again.')}
</EmptyState>
)}
{listQuery.isSuccess && (
<DataTable
columns={columns}
rows={rows}
empty="No candidates are allocated to jobs opened from your requisitions yet."
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
/>
)}
</div>
</div>
</div>
)
}
function RecruiterCandidates() {
const { toast } = useToast() const { toast } = useToast()
const qc = useQueryClient() const qc = useQueryClient()
const location = useLocation() const location = useLocation()

View File

@ -23,7 +23,7 @@
============================================================ */ ============================================================ */
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, Navigate, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import Chart from '../ui/Chart' import Chart from '../ui/Chart'
@ -32,6 +32,7 @@ import ChartCard, { widgetError } from '../ui/ChartCard'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon, KpiTile } from '../ui/primitives' import { Badge, EmptyState, Icon, KpiTile } from '../ui/primitives'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges' import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges'
import { fmtShort, money } from '../data/seed' import { fmtShort, money } from '../data/seed'
@ -134,6 +135,12 @@ function ListGate({ query, title, permission, children, emptyTitle, emptyHint })
} }
export default function Dashboard() { export default function Dashboard() {
const { user } = useAuth()
if (isHiringManager(user)) return <Navigate to="/candidates" replace />
return <DashboardHome />
}
function DashboardHome() {
const navigate = useNavigate() const navigate = useNavigate()
const { user } = useAuth() const { user } = useAuth()
const firstName = (user?.name || 'there').split(' ')[0] const firstName = (user?.name || 'there').split(' ')[0]
@ -419,11 +426,11 @@ export default function Dashboard() {
className="jobapp-row" className="jobapp-row"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => navigate('/jobs', { state: { openJob: j.job_post_id } })} onClick={() => navigate(`/progress?job=${encodeURIComponent(j.job_post_id)}`)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
navigate('/jobs', { state: { openJob: j.job_post_id } }) navigate(`/progress?job=${encodeURIComponent(j.job_post_id)}`)
} }
}} }}
> >

View File

@ -9,7 +9,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' 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 AiFieldAssist from '../ui/AiFieldAssist'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
@ -28,6 +28,7 @@ import * as jobPostsApi from '../api/jobPosts'
import * as assignmentsApi from '../api/assignments' import * as assignmentsApi from '../api/assignments'
import * as tasksApi from '../api/tasks' import * as tasksApi from '../api/tasks'
import * as usersApi from '../api/users' import * as usersApi from '../api/users'
import * as requisitionsApi from '../api/requisitions'
import { empTypes, fmtShort } from '../data/seed' import { empTypes, fmtShort } from '../data/seed'
// Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list). // Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list).
@ -140,6 +141,7 @@ export default function Jobs() {
}, },
onSuccess: (res) => { onSuccess: (res) => {
qc.invalidateQueries({ queryKey: qk.jobs.all() }) qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
setCreating(false) setCreating(false)
if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error') if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error')
else toast('Job created', 'success') else toast('Job created', 'success')
@ -148,6 +150,7 @@ export default function Jobs() {
// 502: row was created but Buffer publish failed refresh the board and // 502: row was created but Buffer publish failed refresh the board and
// say so; a flat "create failed" toast would be wrong. // say so; a flat "create failed" toast would be wrong.
qc.invalidateQueries({ queryKey: qk.jobs.all() }) qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
if (err?.status === 502) { if (err?.status === 502) {
setCreating(false) setCreating(false)
toast('Job created, but publishing failed — see its status on the board.', 'error') toast('Job created, but publishing failed — see its status on the board.', 'error')
@ -161,6 +164,7 @@ export default function Jobs() {
mutationFn: ({ id, body }) => jobsApi.update(id, body), mutationFn: ({ id, body }) => jobsApi.update(id, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() }) qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
setEditing(null) setEditing(null)
toast('Job updated', 'success') toast('Job updated', 'success')
}, },
@ -180,6 +184,7 @@ export default function Jobs() {
mutationFn: (id) => jobsApi.remove(id), mutationFn: (id) => jobsApi.remove(id),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() }) qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
setViewing(null) setViewing(null)
toast('Job deleted', 'success') toast('Job deleted', 'success')
}, },
@ -422,6 +427,7 @@ function SearchSelect({
allowEmpty = false, allowEmpty = false,
emptyLabel = 'Unassigned', emptyLabel = 'Unassigned',
error = false, error = false,
onQueryChange,
}) { }) {
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -436,12 +442,19 @@ function SearchSelect({
return () => document.removeEventListener('mousedown', onDoc) return () => document.removeEventListener('mousedown', onDoc)
}, []) }, [])
useEffect(() => {
if (!onQueryChange || !open) return
onQueryChange(q)
}, [q, open, onQueryChange])
const term = q.trim().toLowerCase() const term = q.trim().toLowerCase()
const filtered = options.filter((o) => { const filtered = onQueryChange
if (!term) return true ? options
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase() : options.filter((o) => {
return hay.includes(term) if (!term) return true
}) const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
return hay.includes(term)
})
return ( return (
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}> <div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
@ -507,12 +520,52 @@ function useRecruiterDirectory() {
}) })
} }
function useRequisitionPicker(initialPicked = null, jobPostId = null) {
const [reqQ, setReqQ] = useState('')
const [debouncedReqQ, setDebouncedReqQ] = useState('')
const [pickedReq, setPickedReq] = useState(initialPicked)
useEffect(() => {
const t = setTimeout(() => setDebouncedReqQ(reqQ.trim()), 250)
return () => clearTimeout(t)
}, [reqQ])
const requisitionsQuery = useQuery({
queryKey: qk.requisitions.search(debouncedReqQ, jobPostId),
queryFn: async () => {
const res = await requisitionsApi.search({ q: debouncedReqQ, jobPostId })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((r) => ({
id: r.id,
name: r.label,
title: r.title || '',
department: r.department || '',
}))
},
placeholderData: keepPreviousData,
retry: false,
})
const requisitionOptions = useMemo(() => {
const rows = requisitionsQuery.data ?? []
if (pickedReq && !rows.some((o) => String(o.id) === String(pickedReq.id))) {
return [pickedReq, ...rows]
}
return rows
}, [requisitionsQuery.data, pickedReq])
return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq }
}
function JobForm({ departmentOptions, busy, onClose, onSubmit }) { function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory() const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory() const recruitersQuery = useRecruiterDirectory()
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
const form = useFormState({ const form = useFormState({
hiring_manager_id: '', hiring_manager_id: '',
current_recruiter_id: '', current_recruiter_id: '',
requisition_id: '',
title: '', title: '',
department: '', department: '',
location: '', location: '',
@ -568,7 +621,6 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
const v = form.values const v = form.values
const errors = {} const errors = {}
if (!v.title.trim()) errors.title = 'Job title is required' 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) const vacancies = Number(v.vacancies)
if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1' if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1'
const expMin = v.experience_min === '' ? null : Number(v.experience_min) const expMin = v.experience_min === '' ? null : Number(v.experience_min)
@ -599,8 +651,9 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
requirements: splitLines(v.requirements), requirements: splitLines(v.requirements),
optional_skills: splitLines(v.optional_skills), optional_skills: splitLines(v.optional_skills),
description: v.description.trim() || null, 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, current_recruiter_id: v.current_recruiter_id || undefined,
requisition_id: v.requisition_id || undefined,
}, imageFile) }, imageFile)
} }
@ -652,6 +705,36 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
> >
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}> <form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid"> <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="form-field col-span-2">
<div className="field-label-row"> <div className="field-label-row">
<label>Title <span className="req">*</span></label> <label>Title <span className="req">*</span></label>
@ -662,7 +745,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
</div> </div>
<div className="form-field"> <div className="form-field">
<label>Hiring manager <span className="req">*</span></label> <label>Hiring manager</label>
<SearchSelect <SearchSelect
options={managersQuery.data ?? []} options={managersQuery.data ?? []}
value={form.values.hiring_manager_id} value={form.values.hiring_manager_id}
@ -670,9 +753,9 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
placeholder="Search hiring managers…" placeholder="Search hiring managers…"
disabled={busy} disabled={busy}
loading={managersQuery.isPending} loading={managersQuery.isPending}
error={Boolean(form.errors.hiring_manager_id)} allowEmpty
emptyLabel="Unassigned"
/> />
<FieldError>{form.errors.hiring_manager_id}</FieldError>
{managersQuery.isError && ( {managersQuery.isError && (
<p className="text-muted text-sm">Could not load hiring managers.</p> <p className="text-muted text-sm">Could not load hiring managers.</p>
)} )}
@ -845,7 +928,19 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) { function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory() const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory() const recruitersQuery = useRecruiterDirectory()
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
j.requisitionId
? {
id: j.requisitionId,
name: j.requisitionLabel || 'Linked requisition',
title: j.requisitionTitle || '',
department: j.requisitionDepartment || '',
}
: null,
j.id,
)
const form = useFormState({ const form = useFormState({
requisition_id: j.requisitionId || '',
title: j.title || '', title: j.title || '',
department: j.department || '', department: j.department || '',
location: j.location || '', location: j.location || '',
@ -885,7 +980,6 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
const title = form.values.title.trim() const title = form.values.title.trim()
const errors = {} const errors = {}
if (!title) errors.title = 'Job title is required' 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) form.setErrors(errors)
if (Object.keys(errors).length) return if (Object.keys(errors).length) return
onSubmit({ onSubmit({
@ -897,8 +991,9 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min), experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min),
experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max), experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
description: form.values.description.trim() || null, 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, current_recruiter_id: form.values.current_recruiter_id || null,
requisition_id: form.values.requisition_id || null,
}) })
} }
@ -919,6 +1014,35 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
> >
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}> <form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid"> <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="form-field col-span-2">
<div className="field-label-row"> <div className="field-label-row">
<label>Title</label> <label>Title</label>
@ -928,7 +1052,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
<FieldError>{form.errors.title}</FieldError> <FieldError>{form.errors.title}</FieldError>
</div> </div>
<div className="form-field"> <div className="form-field">
<label>Hiring manager <span className="req">*</span></label> <label>Hiring manager</label>
<SearchSelect <SearchSelect
options={managersQuery.data ?? []} options={managersQuery.data ?? []}
value={form.values.hiring_manager_id} value={form.values.hiring_manager_id}
@ -936,9 +1060,9 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
placeholder="Search hiring managers…" placeholder="Search hiring managers…"
disabled={busy} disabled={busy}
loading={managersQuery.isPending} loading={managersQuery.isPending}
error={Boolean(form.errors.hiring_manager_id)} allowEmpty
emptyLabel="Unassigned"
/> />
<FieldError>{form.errors.hiring_manager_id}</FieldError>
</div> </div>
<div className="form-field"> <div className="form-field">
<label>Recruiter</label> <label>Recruiter</label>
@ -1034,12 +1158,15 @@ function JobOwnership({ job, canEdit }) {
options={managersQuery.data ?? []} options={managersQuery.data ?? []}
value={job.hiringManagerId || ''} value={job.hiringManagerId || ''}
onChange={(id) => { onChange={(id) => {
if (!id || id === String(job.hiringManagerId || '')) return const next = id || null
patch.mutate({ hiring_manager_id: id }) if (String(next || '') === String(job.hiringManagerId || '')) return
patch.mutate({ hiring_manager_id: next })
}} }}
placeholder="Search hiring managers…" placeholder="Search hiring managers…"
disabled={patch.isPending} disabled={patch.isPending}
loading={managersQuery.isPending} loading={managersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/> />
) : ( ) : (
<p className="text-muted text-sm">{job.hiringManager || '—'}</p> <p className="text-muted text-sm">{job.hiringManager || '—'}</p>
@ -1279,6 +1406,7 @@ function JobDetail({
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div> <div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div> <div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div> <div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div> <div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div> <div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div>
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div> <div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>

View File

@ -0,0 +1,432 @@
/* ============================================================
Progress per-job pipeline stage overview from GET /job/stats/fetch.
Two tabs: Overview (job picker + stage tiles) and All job posts (table).
Counts are unique applicants by email; recruiter comes from
job_posts.current_recruiter_id.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader'
import DataTable from '../ui/DataTable'
import { Badge, EmptyState, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as jobStatsApi from '../api/jobStats'
const STAGES = [
{ key: 'shortlist', label: 'Shortlisted', icon: 'star', tone: 'blue' },
{ key: 'screened', label: 'Screened', icon: 'eye', tone: 'purple' },
{ key: 'assessment', label: 'Assessment', icon: 'check-square', tone: 'amber' },
{ key: 'interviewed', label: 'Interviewed', icon: 'calendar', tone: 'indigo' },
{ key: 'offered', label: 'Offered', icon: 'send', tone: 'teal' },
{ key: 'onHold', label: 'On Hold', icon: 'clock', tone: 'amber' },
{ key: 'rejected', label: 'Rejected', icon: 'x-circle', tone: 'red' },
]
function sumField(jobs, key) {
return jobs.reduce((total, job) => total + (Number(job[key]) || 0), 0)
}
function StageTile({ stage, value, total }) {
const pct = total ? Math.round((value / total) * 100) : 0
return (
<div className={`progress-stage stage-${stage.tone}`}>
<div className="progress-stage-head">
<span className="progress-stage-icon"><Icon name={stage.icon} /></span>
<span className="progress-stage-label">{stage.label}</span>
<span className="progress-stage-pct">{pct}%</span>
</div>
<strong className="progress-stage-value">{value}</strong>
<div className="progress-stage-meter" aria-hidden="true">
<i style={{ width: `${pct}%` }} />
</div>
</div>
)
}
function StageBar({ job }) {
const used = STAGES.reduce((n, stage) => n + (job[stage.key] || 0), 0)
const base = Math.max(used, job.total, 1)
return (
<div className="progress-bar-wrap">
<div className="progress-bar-labels">
<span>Current stage distribution</span>
<span>{job.total} unique applicants</span>
</div>
<div className="progress-bar-track" role="img" aria-label="Stage distribution">
{STAGES.map((stage) => {
const n = job[stage.key] || 0
if (!n) return null
return (
<div
key={stage.key}
className={`progress-bar-seg stage-${stage.tone}`}
title={`${stage.label}: ${n}`}
style={{ width: `${(n / base) * 100}%` }}
/>
)
})}
</div>
</div>
)
}
export default function Progress() {
const [searchParams, setSearchParams] = useSearchParams()
const deepLinkJobId = searchParams.get('job') || ''
const [tab, setTab] = useState('overview')
const [selectedId, setSelectedId] = useState(deepLinkJobId)
const [query, setQuery] = useState('')
const statsQuery = useQuery({
queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
queryFn: async () => {
const res = await jobStatsApi.list({ top: 500, skip: 0 })
const rows = Array.isArray(res?.data) ? res.data : res?.data ? [res.data] : []
return rows.map(jobStatsApi.toJobStatsView)
},
})
const jobs = statsQuery.data ?? []
useEffect(() => {
if (!jobs.length) {
setSelectedId('')
return
}
const matchId = (id) => jobs.some((j) => String(j.id) === String(id))
if (deepLinkJobId && matchId(deepLinkJobId)) {
setSelectedId(String(deepLinkJobId))
setTab('overview')
return
}
if (!selectedId || !matchId(selectedId)) {
setSelectedId(String(jobs[0].id))
}
}, [jobs, selectedId, deepLinkJobId])
const selectJob = (id) => {
const next = String(id || '')
setSelectedId(next)
setTab('overview')
setSearchParams((prev) => {
const nextParams = new URLSearchParams(prev)
if (next) nextParams.set('job', next)
else nextParams.delete('job')
return nextParams
}, { replace: true })
}
const selected = jobs.find((j) => String(j.id) === String(selectedId)) || jobs[0] || null
const visibleJobs = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return jobs
return jobs.filter((job) => (
job.title.toLowerCase().includes(q)
|| (job.department || '').toLowerCase().includes(q)
|| (job.location || '').toLowerCase().includes(q)
|| (job.recruiterName || '').toLowerCase().includes(q)
))
}, [jobs, query])
const totalApplicants = sumField(jobs, 'total')
const activePipeline = sumField(jobs, 'shortlist')
+ sumField(jobs, 'screened')
+ sumField(jobs, 'assessment')
+ sumField(jobs, 'interviewed')
const tableColumns = [
{
key: 'title',
label: 'Job post',
sortable: true,
render: (j) => (
<div>
<div className="cell-primary">{j.title}</div>
<div className="cell-sub">
{[j.department, j.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
),
},
{
key: 'recruiterName',
label: 'Recruiter',
sortable: true,
render: (j) => (
j.recruiterName
? <b>{j.recruiterName}</b>
: <span className="text-muted">Unassigned</span>
),
},
{
key: 'status',
label: 'Status',
sortable: true,
render: (j) => <Badge>{j.status}</Badge>,
},
{ key: 'total', label: 'Applicants', sortable: true, align: 'right', render: (j) => <b>{j.total}</b> },
{ key: 'shortlist', label: 'Shortlisted', sortable: true, align: 'right' },
{ key: 'screened', label: 'Screened', sortable: true, align: 'right' },
{ key: 'interviewed', label: 'Interviewed', sortable: true, align: 'right' },
{ key: 'offered', label: 'Offered', sortable: true, align: 'right' },
{
key: 'onHold',
label: 'On hold',
sortable: true,
align: 'right',
render: (j) => <span className="text-warning">{j.onHold}</span>,
},
{
key: 'rejected',
label: 'Rejected',
sortable: true,
align: 'right',
render: (j) => <span className="text-danger">{j.rejected}</span>,
},
]
return (
<div className="page progress-page">
<PageHeader
title="Progress"
sub="Candidate progress across every job post, at a glance"
/>
<div className="tabs" role="tablist" aria-label="Progress views">
<button
type="button"
className={`tab ${tab === 'overview' ? 'active' : ''}`}
onClick={() => setTab('overview')}
>
<Icon name="dashboard" /> Overview
</button>
<button
type="button"
className={`tab ${tab === 'jobs' ? 'active' : ''}`}
onClick={() => setTab('jobs')}
>
<Icon name="briefcase" /> All job posts
</button>
</div>
{statsQuery.isPending && (
<div className="card"><div className="card-body"><SkeletonRows rows={6} /></div></div>
)}
{statsQuery.isError && (
<div className="card"><div className="card-body">
<EmptyState icon="alert" title="Couldnt load job progress">
{friendlyAuthError(statsQuery.error, 'The server did not answer.')}
{' '}This screen needs <code>jobs.view</code> or <code>pipeline.view</code>.
</EmptyState>
</div></div>
)}
{!statsQuery.isPending && !statsQuery.isError && jobs.length === 0 && (
<div className="card"><div className="card-body">
<EmptyState icon="briefcase" title="No job posts yet">
Open a requisition to start tracking candidate progress.
</EmptyState>
</div></div>
)}
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && tab === 'overview' && selected && (
<>
<div className="grid g-kpi mb-18">
<KpiCard
icon="users"
tone="i-indigo"
label="Total applicants"
value={totalApplicants}
foot={`Across ${jobs.length} job post${jobs.length === 1 ? '' : 's'}`}
/>
<KpiCard
icon="pipeline"
tone="i-purple"
label="Active pipeline"
value={activePipeline}
foot="Currently progressing"
/>
<KpiCard
icon="calendar"
tone="i-blue"
label="Interviewed"
value={sumField(jobs, 'interviewed')}
foot="Candidate interviews"
/>
<KpiCard
icon="send"
tone="i-green"
label="Offers made"
value={sumField(jobs, 'offered')}
foot={`${sumField(jobs, 'hired')} candidates hired`}
/>
</div>
<div className="card mb-18">
<div className="card-head progress-filter-head">
<div>
<h3>Pipeline at a glance</h3>
<span className="ch-sub">Select a job post to inspect its current candidate distribution</span>
</div>
<select
className="select"
value={selectedId}
onChange={(e) => selectJob(e.target.value)}
aria-label="Select job post"
>
{jobs.map((job) => (
<option key={job.id} value={String(job.id)}>{job.title}</option>
))}
</select>
</div>
<div className="card-body">
<div className="progress-selected">
<div>
{selected.department && (
<span className="progress-eyebrow">{selected.department}</span>
)}
<h2>{selected.title}</h2>
<p className="progress-meta">
{selected.location && (
<span><Icon name="map" /> {selected.location}</span>
)}
<span>
Recruiter ·{' '}
{selected.recruiterName
? <b>{selected.recruiterName}</b>
: <span className="text-muted">Unassigned</span>}
</span>
</p>
</div>
<div className="progress-selected-total">
<strong>{selected.total}</strong>
<span>unique applicants</span>
</div>
<Badge>{selected.status}</Badge>
</div>
<div className="progress-stage-grid">
{STAGES.map((stage) => (
<StageTile
key={stage.key}
stage={stage}
value={selected[stage.key] || 0}
total={selected.total}
/>
))}
</div>
<StageBar job={selected} />
</div>
</div>
<div className="grid g-2">
<div className="card">
<div className="card-head">
<div>
<h3>Attention needed</h3>
<span className="ch-sub">Where attention is needed</span>
</div>
</div>
<div className="card-body progress-health">
<div>
<span className="progress-health-icon i-amber"><Icon name="clock" /></span>
<div>
<strong>{selected.onHold} candidates on hold</strong>
<span>Review before the next hiring round</span>
</div>
</div>
<div>
<span className="progress-health-icon i-red"><Icon name="x-circle" /></span>
<div>
<strong>{selected.rejected} rejected</strong>
<span>
{selected.total
? `${Math.round((selected.rejected / selected.total) * 100)}% of total applicants`
: 'No applicants yet'}
</span>
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Hiring outcome</h3>
<span className="ch-sub">Selected job post</span>
</div>
<Badge>{selected.status}</Badge>
</div>
<div className="card-body">
<div className="info-grid">
<div className="info-item">
<div className="il">Offers</div>
<div className="iv">{selected.offered}</div>
</div>
<div className="info-item">
<div className="il">Hired</div>
<div className="iv">{selected.hired}</div>
</div>
<div className="info-item">
<div className="il">Interview rate</div>
<div className="iv">
{selected.total
? `${Math.round((selected.interviewed / selected.total) * 100)}%`
: '—'}
</div>
</div>
<div className="info-item">
<div className="il">Offer-to-hire</div>
<div className="iv">
{selected.offered
? `${Math.round((selected.hired / selected.offered) * 100)}%`
: '—'}
</div>
</div>
</div>
</div>
</div>
</div>
</>
)}
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && tab === 'jobs' && (
<div className="card">
<div className="card-head">
<div>
<h3>All job posts</h3>
<span className="ch-sub">{visibleJobs.length} role{visibleJobs.length === 1 ? '' : 's'}</span>
</div>
</div>
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search jobs, teams, recruiters…"
/>
</div>
</div>
</div>
<DataTable
columns={tableColumns}
rows={visibleJobs}
pageSize={8}
empty="No job posts match this search."
/>
</div>
)}
</div>
)
}

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). */ /** Real data: GET /users/fetch (requires rbac_users.view). */
function Users() { function Users() {
const { toast } = useToast() // const { toast } = useToast() // used by Invite User button above
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const usersQuery = useQuery({ const usersQuery = useQuery({
queryKey: qk.users.list(), queryKey: qk.users.list(),
@ -289,9 +289,11 @@ function Users() {
{usersQuery.isPending ? 'Loading…' : `${users.length} users`} {usersQuery.isPending ? 'Loading…' : `${users.length} users`}
</span> </span>
</div> </div>
{/* Hidden: Invite User restore this block to show the button again.
<button className="btn btn-primary btn-sm" onClick={() => toast('Invite sent', 'success')}> <button className="btn btn-primary btn-sm" onClick={() => toast('Invite sent', 'success')}>
<Icon name="plus" /> Invite User <Icon name="plus" /> Invite User
</button> </button>
*/}
</div> </div>
{usersQuery.isError ? ( {usersQuery.isError ? (

View File

@ -1746,12 +1746,120 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* The four-form switcher must wrap rather than overflow on narrow screens. */ /* The four-form switcher must wrap rather than overflow on narrow screens. */
.cand-page .seg { flex-wrap: wrap; } .cand-page .seg { flex-wrap: wrap; }
/* Rating-table scale header: full words down to 640px, bare numbers below. */ /* Rating-table scale header: full words down to 640px, 25%/50%/75%/100% below. */
.hf-scale-short { display: none; } .hf-scale-short { display: none; }
/* Empty-state CTA on the hiring forms: full-size, and full-width on phones. */ /* Empty-state CTA on the hiring forms: full-size, and full-width on phones. */
.hf-cta { padding: 11px 26px; font-size: 14.5px; } .hf-cta { padding: 11px 26px; font-size: 14.5px; }
/* ================= PROGRESS (job-post stage overview) ================= */
.progress-filter-head { flex-wrap: wrap; }
.progress-filter-head .select { min-width: 240px; }
.progress-selected {
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
flex-wrap: wrap; margin-bottom: 18px;
}
.progress-eyebrow {
display: inline-block; font-size: var(--fs-xs); font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; color: var(--primary); margin-bottom: 4px;
}
.progress-selected h2 {
font-family: var(--font-display); font-size: var(--fs-xl); font-weight: 600;
letter-spacing: -.02em; margin: 0 0 6px;
}
.progress-meta {
display: flex; flex-wrap: wrap; gap: 14px; align-items: center;
color: var(--text-2); font-size: var(--fs-sm); margin: 0;
}
.progress-meta svg { width: 14px; height: 14px; vertical-align: -2px; margin-right: 4px; }
.progress-selected-total {
display: flex; flex-direction: column; align-items: center; text-align: center;
min-width: 110px;
}
.progress-selected-total strong {
display: block; font-family: var(--font-display); font-size: 32px; font-weight: 600;
letter-spacing: -.02em; line-height: 1;
}
.progress-selected-total span {
display: block; font-size: var(--fs-xs); color: var(--text-3); margin-top: 4px;
}
.progress-stage-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px; margin-bottom: 18px;
}
.progress-stage {
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px;
}
.progress-stage-head {
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
}
.progress-stage-icon {
width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; flex: 0 0 auto;
}
.progress-stage-icon svg { width: 13px; height: 13px; }
.progress-stage-label { font-size: var(--fs-sm); color: var(--text-2); font-weight: 500; min-width: 0; }
.progress-stage-pct { margin-left: auto; font-size: var(--fs-xs); color: var(--text-3); font-weight: 600; }
.progress-stage-value {
display: block; font-family: var(--font-display); font-size: 24px; font-weight: 600;
letter-spacing: -.02em; line-height: 1.1; margin-bottom: 10px;
}
.progress-stage-meter {
height: 4px; border-radius: 99px; background: var(--bg-sunken); overflow: hidden;
}
.progress-stage-meter > i { display: block; height: 100%; border-radius: 99px; }
.progress-stage.stage-blue .progress-stage-icon { background: var(--info-soft); color: var(--info); }
.progress-stage.stage-blue .progress-stage-meter > i { background: var(--info); }
.progress-stage.stage-purple .progress-stage-icon { background: var(--purple-soft); color: var(--purple); }
.progress-stage.stage-purple .progress-stage-meter > i { background: var(--purple); }
.progress-stage.stage-amber .progress-stage-icon { background: var(--warning-soft); color: var(--warning); }
.progress-stage.stage-amber .progress-stage-meter > i { background: var(--warning); }
.progress-stage.stage-indigo .progress-stage-icon { background: var(--primary-soft); color: var(--primary); }
.progress-stage.stage-indigo .progress-stage-meter > i { background: var(--primary); }
.progress-stage.stage-teal .progress-stage-icon { background: var(--teal-soft); color: var(--teal); }
.progress-stage.stage-teal .progress-stage-meter > i { background: var(--teal); }
.progress-stage.stage-red .progress-stage-icon { background: var(--danger-soft); color: var(--danger); }
.progress-stage.stage-red .progress-stage-meter > i { background: var(--danger); }
.progress-bar-wrap { margin-top: 4px; }
.progress-bar-labels {
display: flex; justify-content: space-between; gap: 12px;
font-size: var(--fs-sm); color: var(--text-2); margin-bottom: 8px;
}
.progress-bar-labels span:last-child { color: var(--text-3); }
.progress-bar-track {
display: flex; gap: 3px; height: 8px; border-radius: 99px; overflow: hidden;
background: var(--bg-sunken);
}
.progress-bar-seg { min-width: 3px; height: 100%; border-radius: 99px; }
.progress-bar-seg.stage-blue { background: var(--info); }
.progress-bar-seg.stage-purple { background: var(--purple); }
.progress-bar-seg.stage-amber { background: var(--warning); }
.progress-bar-seg.stage-indigo { background: var(--primary); }
.progress-bar-seg.stage-teal { background: var(--teal); }
.progress-bar-seg.stage-red { background: var(--danger); }
.progress-health { display: flex; flex-direction: column; gap: 16px; }
.progress-health > div { display: flex; align-items: flex-start; gap: 12px; }
.progress-health-icon {
width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; flex: 0 0 auto;
}
.progress-health-icon svg { width: 18px; height: 18px; }
.progress-health strong { display: block; font-size: var(--fs-base); margin-bottom: 2px; }
.progress-health span { font-size: var(--fs-sm); color: var(--text-3); }
.text-warning { color: var(--warning); }
.text-danger { color: var(--danger); }
@media (max-width: 900px) {
.progress-selected-total { align-items: flex-start; text-align: left; }
}
@media (max-width: 640px) {
.progress-stage-grid { grid-template-columns: repeat(2, 1fr); }
.progress-filter-head .select { width: 100%; min-width: 0; }
}
/* ============================================================ /* ============================================================
640 / 400 consolidated phone rules for the late bolt-on sections 640 / 400 consolidated phone rules for the late bolt-on sections
(Dashboard v2 grid, candidate page, hiring forms). Kept in ONE block (Dashboard v2 grid, candidate page, hiring forms). Kept in ONE block

View File

@ -23,7 +23,7 @@ export default defineConfig({
// VITE_API_TARGET repoints the proxy when the API runs elsewhere // VITE_API_TARGET repoints the proxy when the API runs elsewhere
// (e.g. 8001 locally because another service holds 8000). // (e.g. 8001 locally because another service holds 8000).
proxy: { 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', target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8000',
changeOrigin: true, changeOrigin: true,
// Several API prefixes double as SPA routes (/jobs, /inbox, …). // Several API prefixes double as SPA routes (/jobs, /inbox, …).