Add Progress bar
parent
2998ba94a7
commit
8c5549a201
|
|
@ -85,3 +85,4 @@ tests/**
|
||||||
/backend/tests/**
|
/backend/tests/**
|
||||||
frontend/dist/**
|
frontend/dist/**
|
||||||
nginx.conf
|
nginx.conf
|
||||||
|
smoke.test.mjs
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}%"
|
||||||
|
|
|
||||||
|
|
@ -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 1–4; 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 0–100.
|
||||||
|
|
||||||
|
New ticks are 25/50/75/100 and averages are already percentages. Legacy
|
||||||
|
1–4 ticks or means (0, 4] convert once via (score / 4) × 100.
|
||||||
|
"""
|
||||||
|
if score is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = float(score)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if 0 < number <= 4:
|
||||||
|
return round((number / 4) * 100, 2)
|
||||||
|
return round(number, 2)
|
||||||
|
|
||||||
|
|
||||||
def normalize_sections(form_type: str, sections):
|
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 1–4 ticks are coerced to the matching percent
|
||||||
|
before averaging. Raises ValueError on unknown section/criterion keys or
|
||||||
|
ratings outside the scale (422 material).
|
||||||
"""
|
"""
|
||||||
definition = FORM_DEFINITIONS.get(form_type)
|
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 0–100) appears only once all three exist. Returns None when
|
||||||
|
neither evaluation exists. Legacy 1–4 section averages are converted
|
||||||
|
through to_percent so mixed old/new rows stay comparable.
|
||||||
"""
|
"""
|
||||||
latest = {}
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- 024_manager_candidates_rbac.sql
|
||||||
|
-- Manual one-shot: hiring_manager can list candidates on their requisition
|
||||||
|
-- jobs and write notes on those profiles. Form fill already comes from
|
||||||
|
-- hiring_forms (interviews.create/edit) + analytics_dashboard (interviews.view).
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
--
|
||||||
|
-- Users must log in again after this applies — the frontend caches /users/me.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 1. Bundle: candidates.view / create / edit (list + notes)
|
||||||
|
-- =============================================================================
|
||||||
|
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||||
|
SELECT
|
||||||
|
'manager_candidates',
|
||||||
|
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
|
||||||
|
(
|
||||||
|
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||||
|
FROM app.permission_tags
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND tag_name IN ('candidates.view', 'candidates.create', 'candidates.edit')
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM app.permissions WHERE name = 'manager_candidates'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 2. Attach the bundle to hiring_manager only
|
||||||
|
-- =============================================================================
|
||||||
|
UPDATE app.roles r
|
||||||
|
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM app.permissions p
|
||||||
|
WHERE p.name = 'manager_candidates'
|
||||||
|
AND r.role_name = 'hiring_manager'
|
||||||
|
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
-- 025_manager_role_candidates_rbac.sql
|
||||||
|
-- The Access Control role named Manager (Ahmed Baig) is not the seeded
|
||||||
|
-- hiring_manager role. 024 only attached manager_candidates to hiring_manager,
|
||||||
|
-- so Manager had 0 candidates.* tags and the Candidates nav item never appeared
|
||||||
|
-- (routes.js permission is candidates.view). Same hole broke Calendar:
|
||||||
|
-- GET /interview/fetch is gated on candidates.view, not interviews.view.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql(). Log in again after.
|
||||||
|
|
||||||
|
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||||
|
SELECT
|
||||||
|
'manager_candidates',
|
||||||
|
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
|
||||||
|
(
|
||||||
|
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||||
|
FROM app.permission_tags
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND tag_name IN ('candidates.view', 'candidates.create', 'candidates.edit')
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM app.permissions WHERE name = 'manager_candidates'
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE app.roles r
|
||||||
|
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM app.permissions p
|
||||||
|
WHERE p.name = 'manager_candidates'
|
||||||
|
AND lower(r.role_name) IN ('hiring_manager', 'manager')
|
||||||
|
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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')),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export const ROUTES = [
|
||||||
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
|
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
|
||||||
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },
|
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },
|
||||||
{ path: 'pipeline', title: 'Pipeline', icon: 'pipeline', group: 'Workspace', permission: 'pipeline.view' },
|
{ path: 'pipeline', title: 'Pipeline', icon: 'pipeline', group: 'Workspace', permission: 'pipeline.view' },
|
||||||
|
{ path: 'progress', title: 'Progress', icon: 'trending-up', group: 'Workspace', permission: 'jobs.view' },
|
||||||
|
|
||||||
// --- Recruiting ---
|
// --- Recruiting ---
|
||||||
{ path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' },
|
{ path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' },
|
||||||
|
|
|
||||||
|
|
@ -34,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'
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 0–100. Legacy 1–4 values convert once. */
|
||||||
|
function toPercent(score) {
|
||||||
|
if (score == null || score === '') return null
|
||||||
|
const n = Number(score)
|
||||||
|
if (!Number.isFinite(n)) return null
|
||||||
|
if (n > 0 && n <= 4) return n * 25
|
||||||
|
return Math.round(n * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(score) {
|
||||||
|
const pct = toPercent(score)
|
||||||
|
return pct == null ? null : `${pct}%`
|
||||||
|
}
|
||||||
|
|
||||||
const WORK_LOCATIONS = ['Maymar Office', 'Head Office']
|
const WORK_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 1–4 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({
|
||||||
|
|
|
||||||
|
|
@ -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 (0–100)</label>
|
|
||||||
<input
|
|
||||||
type="number" min="0" max="100"
|
|
||||||
value={form.score}
|
|
||||||
onChange={(e) => set('score', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-field">
|
|
||||||
<label>Notes</label>
|
|
||||||
<textarea value={form.note} onChange={(e) => set('note', e.target.value)} rows={3} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<button className="btn btn-primary btn-sm" disabled={save.isPending} onClick={submit}>
|
|
||||||
{save.isPending ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
disabled={save.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
setForm({
|
|
||||||
review: f.review || REVIEWS[0],
|
|
||||||
score: f.score == null ? '' : String(f.score),
|
|
||||||
note: f.note || '',
|
|
||||||
})
|
|
||||||
setEditing(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="list-row">
|
|
||||||
<Avatar name={f.reviewed_by_name || 'Unknown'} />
|
|
||||||
<div className="lr-main">
|
|
||||||
<div className="lr-title">{f.reviewed_by_name || 'Unknown reviewer'}</div>
|
|
||||||
{f.note && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{f.note}</div>}
|
|
||||||
<div className="lr-sub">
|
|
||||||
{fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}
|
|
||||||
{f.updated_at && f.updated_at !== f.created_at ? ' · revised' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="lr-right" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
{f.review ? <Badge>{f.review}</Badge> : null}
|
|
||||||
{mine && (
|
|
||||||
<button className="act-btn" data-tip="Revise scorecard" aria-label="Revise scorecard" onClick={() => setEditing(true)}>
|
|
||||||
<Icon name="edit" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeedbackTab({ userId, inboxId, rows }) {
|
|
||||||
const { toast } = useToast()
|
|
||||||
const [form, setForm] = useState({ review: REVIEWS[0], score: '', note: '' })
|
|
||||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
|
||||||
|
|
||||||
const create = useProfileWrite({
|
|
||||||
userId,
|
|
||||||
mutationFn: () => candidatesApi.createFeedback({
|
|
||||||
inboxId,
|
|
||||||
review: form.review,
|
|
||||||
score: form.score === '' ? 0 : Number(form.score),
|
|
||||||
note: form.note.trim(),
|
|
||||||
}),
|
|
||||||
success: 'Scorecard submitted',
|
|
||||||
onDone: () => setForm({ review: REVIEWS[0], score: '', note: '' }),
|
|
||||||
})
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
const score = form.score === '' ? 0 : Number(form.score)
|
|
||||||
if (!Number.isFinite(score) || score < 0 || score > 100) {
|
|
||||||
toast('Score must be between 0 and 100', 'warning')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
create.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{rows.length ? (
|
|
||||||
<div className="list-tight">
|
|
||||||
{rows.map((f) => <FeedbackRow key={f.id} row={f} userId={userId} />)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState icon="award" title="No scorecards yet">Be the first to review this candidate.</EmptyState>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="divider" />
|
|
||||||
<h3 className="form-section-title" style={{ marginTop: 0 }}>Submit a scorecard</h3>
|
|
||||||
<div className="form-grid">
|
|
||||||
<div className="form-field">
|
|
||||||
<label>Recommendation</label>
|
|
||||||
<select value={form.review} onChange={(e) => set('review', e.target.value)}>
|
|
||||||
{REVIEWS.map((r) => <option key={r}>{r}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-field">
|
|
||||||
<label>Score (0–100)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={form.score}
|
|
||||||
onChange={(e) => set('score', e.target.value)}
|
|
||||||
placeholder="80"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-field">
|
|
||||||
<label>Notes</label>
|
|
||||||
<textarea
|
|
||||||
value={form.note}
|
|
||||||
onChange={(e) => set('note', e.target.value)}
|
|
||||||
placeholder="What stood out, and what would you probe next round?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
style={{ marginTop: 10 }}
|
|
||||||
disabled={!inboxId || create.isPending}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
<Icon name="plus" /> {create.isPending ? 'Submitting…' : 'Submit Scorecard'}
|
|
||||||
</button>
|
|
||||||
{!inboxId && (
|
|
||||||
<p className="text-muted" style={{ marginTop: 8, fontSize: 12.5 }}>
|
|
||||||
Scorecards attach to an email application — this candidate was added manually,
|
|
||||||
so submitting is unavailable here.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Candidates — the scored-candidate pool, on live backend data.
|
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="Couldn’t load candidates">
|
||||||
|
{friendlyAuthError(listQuery.error, 'Please try again.')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
{listQuery.isSuccess && (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
empty="No candidates are allocated to jobs opened from your requisitions yet."
|
||||||
|
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecruiterCandidates() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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="Couldn’t load job progress">
|
||||||
|
{friendlyAuthError(statsQuery.error, 'The server did not answer.')}
|
||||||
|
{' '}This screen needs <code>jobs.view</code> or <code>pipeline.view</code>.
|
||||||
|
</EmptyState>
|
||||||
|
</div></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!statsQuery.isPending && !statsQuery.isError && jobs.length === 0 && (
|
||||||
|
<div className="card"><div className="card-body">
|
||||||
|
<EmptyState icon="briefcase" title="No job posts yet">
|
||||||
|
Open a requisition to start tracking candidate progress.
|
||||||
|
</EmptyState>
|
||||||
|
</div></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && tab === 'overview' && selected && (
|
||||||
|
<>
|
||||||
|
<div className="grid g-kpi mb-18">
|
||||||
|
<KpiCard
|
||||||
|
icon="users"
|
||||||
|
tone="i-indigo"
|
||||||
|
label="Total applicants"
|
||||||
|
value={totalApplicants}
|
||||||
|
foot={`Across ${jobs.length} job post${jobs.length === 1 ? '' : 's'}`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon="pipeline"
|
||||||
|
tone="i-purple"
|
||||||
|
label="Active pipeline"
|
||||||
|
value={activePipeline}
|
||||||
|
foot="Currently progressing"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon="calendar"
|
||||||
|
tone="i-blue"
|
||||||
|
label="Interviewed"
|
||||||
|
value={sumField(jobs, 'interviewed')}
|
||||||
|
foot="Candidate interviews"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon="send"
|
||||||
|
tone="i-green"
|
||||||
|
label="Offers made"
|
||||||
|
value={sumField(jobs, 'offered')}
|
||||||
|
foot={`${sumField(jobs, 'hired')} candidates hired`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mb-18">
|
||||||
|
<div className="card-head progress-filter-head">
|
||||||
|
<div>
|
||||||
|
<h3>Pipeline at a glance</h3>
|
||||||
|
<span className="ch-sub">Select a job post to inspect its current candidate distribution</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={selectedId}
|
||||||
|
onChange={(e) => 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue