Add Progress bar

pull/45/head
ahmed.mujtaba 2026-09-02 13:33:11 +05:00
parent 2998ba94a7
commit 8c5549a201
38 changed files with 1744 additions and 340 deletions

1
.gitignore vendored
View File

@ -85,3 +85,4 @@ tests/**
/backend/tests/** /backend/tests/**
frontend/dist/** frontend/dist/**
nginx.conf nginx.conf
smoke.test.mjs

View File

@ -80,15 +80,18 @@ async def search_requisitions(
), ),
q: str | None = Query(None), q: str | None = Query(None),
top: int = Query(50, ge=1, le=100), top: int = Query(50, ge=1, le=100),
job_post_id: uuid.UUID | None = Query(None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""Searchable picker for job create: `{position_title} - {department}`. """Searchable picker for job create: `{position_title} - {department}`.
`q` matches either field (ilike). Empty `q` returns recent rows. `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: try:
service = RequisitionForm(session=session) service = RequisitionForm(session=session)
data = await service.search(q, top=top) data = await service.search(q, top=top, job_post_id=job_post_id)
return JSONResponse(content={"data": data, "status_code": 200}) return JSONResponse(content={"data": data, "status_code": 200})
except HTTPException: except HTTPException:
raise raise
@ -102,6 +105,8 @@ async def fetch_requisition_form(
form_id:str=Query(None), form_id:str=Query(None),
session:AsyncSession=Depends(get_session), 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: try:
service=RequisitionForm(session=session) service=RequisitionForm(session=session)
data=await service.get_form_by_id(form_id,current_user) data=await service.get_form_by_id(form_id,current_user)
@ -171,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

@ -87,14 +87,35 @@ class Requisition(SQLModel, table=True):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
async def search(cls, session: AsyncSession, q: str | None = None, *, top: int = 50): 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). """Dropdown rows: match position_title or department (either side).
Empty `q` returns the most recent non-deleted rows so the picker has a 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 list before the user types. Not scoped to created_by job creators
need the org-wide list, not only requisitions they opened themselves. 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 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() term = (q or "").strip()
if term: if term:
like = f"%{term}%" like = f"%{term}%"

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,9 +42,9 @@ 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,

View File

@ -7,9 +7,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from candidate_forms.models import CandidateForms, Requisition, _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,
combined_summary, combined_summary,
normalize_fields,
normalize_sections,
) )
from candidate_forms.serializers import ( from candidate_forms.serializers import (
serialize_form, serialize_form,
@ -18,10 +22,12 @@ from candidate_forms.serializers import (
) )
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")
@ -54,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
@ -81,16 +116,22 @@ 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:
@ -181,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,
@ -196,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:
@ -218,6 +283,13 @@ 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:
has_interview = False has_interview = False
if inbox_id is not None: if inbox_id is not None:
@ -237,6 +309,8 @@ class CandidateForm:
if form_type != "requisition" and interviewer_id is None: if form_type != "requisition" and interviewer_id is None:
interviewer_id = _user_id(current_user) interviewer_id = _user_id(current_user)
form_date = _aware(payload.get("form_date")) or _now() form_date = _aware(payload.get("form_date")) or _now()
sections, overall_score = _score_sections(form_type, payload.get("sections"))
fields = _score_fields(form_type, payload.get("fields"))
row = await CandidateForms.insert_form( row = await CandidateForms.insert_form(
self.session, self.session,
@ -247,9 +321,10 @@ class CandidateForm:
"job_post_id": job_post_id, "job_post_id": job_post_id,
"interviewer_id": interviewer_id, "interviewer_id": interviewer_id,
"form_date": form_date, "form_date": form_date,
"sections": payload.get("sections"), "sections": sections,
"fields": payload.get("fields"), "fields": fields,
"recommendation": payload.get("recommendation"), "overall_score": overall_score,
"recommendation": _recommendation(form_type, payload.get("recommendation")),
"created_by": _user_id(current_user), "created_by": _user_id(current_user),
}, },
) )
@ -270,6 +345,13 @@ 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 = {} fields = {}
if "interviewer_id" in payload: if "interviewer_id" in payload:
@ -277,11 +359,13 @@ class CandidateForm:
if "form_date" in payload: if "form_date" in payload:
fields["form_date"] = _aware(payload.get("form_date")) fields["form_date"] = _aware(payload.get("form_date"))
if "sections" in payload: if "sections" in payload:
fields["sections"] = payload.get("sections") sections, overall_score = _score_sections(row.form_type, payload.get("sections"))
fields["sections"] = sections
fields["overall_score"] = overall_score
if "fields" in payload: if "fields" in payload:
fields["fields"] = payload.get("fields") fields["fields"] = _score_fields(row.form_type, payload.get("fields"))
if "recommendation" in payload: if "recommendation" in payload:
fields["recommendation"] = payload.get("recommendation") 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")
@ -302,6 +386,16 @@ 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")
@ -330,7 +424,9 @@ class RequisitionForm:
async def get_form_by_id(self, form_id, current_user): async def get_form_by_id(self, form_id, current_user):
created_by = _user_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: if form_id:
row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by) row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
if not row: if not row:
@ -339,6 +435,8 @@ class RequisitionForm:
rows = await Requisition.get_form_by_id(self.session, created_by=created_by) rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
return [serialize_requisition(r) for r in rows] return [serialize_requisition(r) for r in rows]
async def search(self, q, top=50): async def search(self, q, top=50, job_post_id=None):
rows = await Requisition.search(self.session, q, top=top) rows = await Requisition.search(
self.session, q, top=top, job_post_id=job_post_id,
)
return [serialize_requisition_option(r) for r in rows] 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
@ -236,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))
@ -252,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))
@ -728,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),
@ -865,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),
@ -876,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
@ -999,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

@ -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,8 +2,9 @@ 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
@ -47,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"})
@ -197,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.
@ -215,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."""
@ -397,6 +616,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()

View File

@ -47,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,
@ -72,6 +73,8 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
"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_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,
@ -80,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

@ -27,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")
@ -234,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)
@ -332,6 +354,12 @@ class JobPost:
req=await Requisition.get_form_by_id(self.session,record_id=str(raw)) req=await Requisition.get_form_by_id(self.session,record_id=str(raw))
if not req: if not req:
raise HTTPException(status_code=404,detail="Requisition not found") 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 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")

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

@ -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
@ -203,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|forms|requisitions)(/|$) { 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

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

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

@ -76,6 +76,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

@ -26,10 +26,15 @@ export function getById(formId) {
return request('/forms/requisition/fetch', { params: { form_id: formId } }) return request('/forms/requisition/fetch', { params: { form_id: formId } })
} }
/** Searchable picker — GET /forms/requisition/search. `q` matches title or department. */ /** Searchable picker GET /forms/requisition/search. `q` matches title or department.
export function search({ q, top } = {}) { * `jobPostId` keeps the job's current requisition in the list while editing. */
export function search({ q, top, jobPostId } = {}) {
return request('/forms/requisition/search', { return request('/forms/requisition/search', {
params: { q: q || undefined, top }, params: {
q: q || undefined,
top,
job_post_id: jobPostId || undefined,
},
}) })
} }

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

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

View File

@ -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],
@ -128,7 +130,7 @@ export const qk = {
all: () => ['requisitions'], all: () => ['requisitions'],
list: () => ['requisitions', 'list'], list: () => ['requisitions', 'list'],
detail: (id) => ['requisitions', 'detail', id], detail: (id) => ['requisitions', 'detail', id],
search: (q = '') => ['requisitions', 'search', q], search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
}, },
interviews: { interviews: {
all: () => ['interviews'], all: () => ['interviews'],

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

@ -13,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'
@ -23,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']
@ -61,7 +77,8 @@ 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('interview_analysis') const [seg, setSeg] = useState('interview_analysis')
@ -82,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),
@ -93,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) {
@ -135,9 +152,12 @@ export default function CandidateFormsTab({ userId, live }) {
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 = {
...(inboxId
? { inbox_id: Number(inboxId) } ? { inbox_id: Number(inboxId) }
: { manual_upload_candidate_id: manualId } : { manual_upload_candidate_id: manualId }),
...(live?.assigned_job_post_id ? { job_post_id: live.assigned_job_post_id } : {}),
}
const done = { const done = {
interview_analysis: rows.some((r) => r.form_type === 'interview_analysis'), interview_analysis: rows.some((r) => r.form_type === 'interview_analysis'),
@ -147,7 +167,7 @@ export default function CandidateFormsTab({ userId, live }) {
const segTabs = [ const segTabs = [
{ 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(
@ -185,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} />
)} )}
</> </>
@ -194,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>
) )
@ -243,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
} }
@ -262,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}
@ -283,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>
@ -311,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>
) )
@ -350,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 {
@ -413,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({

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)
@ -230,7 +231,6 @@ export default function CandidateProfile({
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

@ -1,5 +1,5 @@
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Chart, { ChartLegend } from '../ui/Chart' import Chart, { ChartLegend } from '../ui/Chart'
@ -9,6 +9,7 @@ import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, Icon, KpiTile, PRIORITY_CLASS, ProgressBar } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, KpiTile, PRIORITY_CLASS, ProgressBar } 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 { ApiError, friendlyAuthError } from '../lib/errors' import { ApiError, friendlyAuthError } from '../lib/errors'
import { fmtShort, money, initials as initialsOf, avatarColor } from '../data/seed' import { fmtShort, money, initials as initialsOf, avatarColor } from '../data/seed'
@ -159,6 +160,12 @@ function clock(d) {
} }
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, can } = useAuth() const { user, can } = useAuth()
const { toast } = useToast() const { toast } = useToast()

View File

@ -141,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')
@ -149,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')
@ -162,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')
}, },
@ -181,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')
}, },
@ -516,12 +520,10 @@ function useRecruiterDirectory() {
}) })
} }
function JobForm({ departmentOptions, busy, onClose, onSubmit }) { function useRequisitionPicker(initialPicked = null, jobPostId = null) {
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const [reqQ, setReqQ] = useState('') const [reqQ, setReqQ] = useState('')
const [debouncedReqQ, setDebouncedReqQ] = useState('') const [debouncedReqQ, setDebouncedReqQ] = useState('')
const [pickedReq, setPickedReq] = useState(null) const [pickedReq, setPickedReq] = useState(initialPicked)
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setDebouncedReqQ(reqQ.trim()), 250) const t = setTimeout(() => setDebouncedReqQ(reqQ.trim()), 250)
@ -529,9 +531,9 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
}, [reqQ]) }, [reqQ])
const requisitionsQuery = useQuery({ const requisitionsQuery = useQuery({
queryKey: qk.requisitions.search(debouncedReqQ), queryKey: qk.requisitions.search(debouncedReqQ, jobPostId),
queryFn: async () => { queryFn: async () => {
const res = await requisitionsApi.search({ q: debouncedReqQ }) const res = await requisitionsApi.search({ q: debouncedReqQ, jobPostId })
const rows = Array.isArray(res?.data) ? res.data : [] const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
@ -552,6 +554,14 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
return rows return rows
}, [requisitionsQuery.data, pickedReq]) }, [requisitionsQuery.data, pickedReq])
return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq }
}
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
const form = useFormState({ const form = useFormState({
hiring_manager_id: '', hiring_manager_id: '',
current_recruiter_id: '', current_recruiter_id: '',
@ -918,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 || '',
@ -971,6 +993,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
description: form.values.description.trim() || null, description: form.values.description.trim() || null,
hiring_manager_id: form.values.hiring_manager_id || null, 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,
}) })
} }
@ -991,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>
@ -1354,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,410 @@
/* ============================================================
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 { 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 [tab, setTab] = useState('overview')
const [selectedId, setSelectedId] = useState('')
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
}
if (!selectedId || !jobs.some((j) => j.id === selectedId)) {
setSelectedId(jobs[0].id)
}
}, [jobs, selectedId])
const selected = jobs.find((j) => j.id === 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) => setSelectedId(e.target.value)}
aria-label="Select job post"
>
{jobs.map((job) => (
<option key={job.id} value={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

@ -1734,12 +1734,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