Add Progress bar
parent
2998ba94a7
commit
8c5549a201
|
|
@ -85,3 +85,4 @@ tests/**
|
|||
/backend/tests/**
|
||||
frontend/dist/**
|
||||
nginx.conf
|
||||
smoke.test.mjs
|
||||
|
|
@ -80,15 +80,18 @@ async def search_requisitions(
|
|||
),
|
||||
q: str | None = Query(None),
|
||||
top: int = Query(50, ge=1, le=100),
|
||||
job_post_id: uuid.UUID | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Searchable picker for job create: `{position_title} - {department}`.
|
||||
|
||||
`q` matches either field (ilike). Empty `q` returns recent rows.
|
||||
Linked requisitions are omitted (1:1 with job posts). Pass `job_post_id`
|
||||
on edit so the job's current requisition remains selectable until unlinked.
|
||||
"""
|
||||
try:
|
||||
service = RequisitionForm(session=session)
|
||||
data = await service.search(q, top=top)
|
||||
data = await service.search(q, top=top, job_post_id=job_post_id)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -102,6 +105,8 @@ async def fetch_requisition_form(
|
|||
form_id:str=Query(None),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
"""Requisition table. Admins get every non-deleted row (job link does not
|
||||
hide anything). Other roles stay scoped to created_by."""
|
||||
try:
|
||||
service=RequisitionForm(session=session)
|
||||
data=await service.get_form_by_id(form_id,current_user)
|
||||
|
|
@ -171,6 +176,7 @@ async def fetch_forms(
|
|||
service = CandidateForm(session=session)
|
||||
data, summary, total = await service.get_forms(
|
||||
form_id, inbox_id, manual_upload_candidate_id, job_post_id, form_type, top, skip,
|
||||
current_user=current_user,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"data": data, "summary": summary, "total": total, "status_code": 200}
|
||||
|
|
|
|||
|
|
@ -87,14 +87,35 @@ class Requisition(SQLModel, table=True):
|
|||
return list(result.scalars().all())
|
||||
|
||||
@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).
|
||||
|
||||
Empty `q` returns the most recent non-deleted rows so the picker has a
|
||||
list before the user types. Not scoped to created_by — job creators
|
||||
need the org-wide list, not only requisitions they opened themselves.
|
||||
|
||||
job_posts.requisition_id is 1:1. Hide requisitions already linked to a
|
||||
live job post. Pass `job_post_id` when editing so that job's current
|
||||
requisition stays in the list until the link is cleared.
|
||||
"""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
statement = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||
held = select(JobPosts.requisition_id).where(
|
||||
JobPosts.requisition_id.is_not(None),
|
||||
JobPosts.is_deleted == False, # noqa: E712
|
||||
)
|
||||
except_uid = JobPosts._as_uuid(job_post_id) if job_post_id else None
|
||||
if except_uid is not None:
|
||||
held = held.where(JobPosts.id != except_uid)
|
||||
statement = statement.where(cls.id.notin_(held))
|
||||
term = (q or "").strip()
|
||||
if term:
|
||||
like = f"%{term}%"
|
||||
|
|
|
|||
|
|
@ -9,16 +9,17 @@ into every saved row so historical records survive future renames.
|
|||
|
||||
FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit")
|
||||
|
||||
RATING_MIN = 1
|
||||
RATING_MAX = 4
|
||||
RATING_POINTS = (25, 50, 75, 100)
|
||||
# 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 = {
|
||||
1: "Below Average (1)",
|
||||
2: "Average (2)",
|
||||
3: "Good (3)",
|
||||
4: "Excellent (4)",
|
||||
25: "Below Average (25%)",
|
||||
50: "Average (50%)",
|
||||
75: "Good (75%)",
|
||||
100: "Excellent (100%)",
|
||||
}
|
||||
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."
|
||||
)
|
||||
|
||||
|
|
@ -194,6 +195,7 @@ def definitions_payload() -> dict:
|
|||
"form_types": list(FORM_TYPES),
|
||||
"forms": FORM_DEFINITIONS,
|
||||
"rating_labels": {str(k): v for k, v in RATING_LABELS.items()},
|
||||
"rating_points": list(RATING_POINTS),
|
||||
"recommendations": list(RECOMMENDATIONS),
|
||||
"recommendation_labels": dict(RECOMMENDATION_LABELS),
|
||||
"employment_types": list(EMPLOYMENT_TYPES),
|
||||
|
|
@ -211,9 +213,10 @@ def _coerce_rating(value):
|
|||
raise ValueError(f"rating must be a number, got {value!r}")
|
||||
if number != int(number):
|
||||
raise ValueError(f"rating must be a whole number, got {value!r}")
|
||||
rating = int(number)
|
||||
if rating < RATING_MIN or rating > RATING_MAX:
|
||||
raise ValueError(f"rating must be between {RATING_MIN} and {RATING_MAX}, got {rating}")
|
||||
rating = _LEGACY_TICK.get(int(number), int(number))
|
||||
if rating not in RATING_POINTS:
|
||||
allowed = ", ".join(str(p) for p in RATING_POINTS)
|
||||
raise ValueError(f"rating must be one of {allowed}, got {rating}")
|
||||
return rating
|
||||
|
||||
|
||||
|
|
@ -224,15 +227,34 @@ def _mean(values, digits=2):
|
|||
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):
|
||||
"""Validate submitted rated sections against the form definition and
|
||||
recompute all derived numbers. Returns (normalized_sections, overall_score).
|
||||
|
||||
Every definition section is emitted in definition order with denormalized
|
||||
labels; submitted per-criterion ratings are merged in; client-sent averages
|
||||
are discarded and recomputed (mean of the non-null ratings, 2 dp). The
|
||||
overall score is the mean of the section averages. Raises ValueError on
|
||||
unknown section/criterion keys or out-of-range ratings (422 material).
|
||||
are discarded and recomputed. Criterion ticks are 25/50/75/100. A section
|
||||
average is the mean of those percentages; the overall score is the mean of
|
||||
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)
|
||||
if definition is None:
|
||||
|
|
@ -278,7 +300,7 @@ def normalize_sections(form_type: str, sections):
|
|||
}
|
||||
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:
|
||||
section_averages.append(average)
|
||||
normalized.append(
|
||||
|
|
@ -333,8 +355,10 @@ def combined_summary(rows):
|
|||
`rows` are candidate_forms records (attribute access: form_type, created_at,
|
||||
sections). The latest interview_analysis row supplies the technical and
|
||||
behavioral averages, the latest cultural_fit row the cultural average.
|
||||
The combined overall (mean of the three section averages, 2 dp) appears
|
||||
only once all three exist. Returns None when neither evaluation exists.
|
||||
The combined overall (mean of the three section averages, 2 dp, already
|
||||
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 = {}
|
||||
for row in rows:
|
||||
|
|
@ -351,7 +375,7 @@ def combined_summary(rows):
|
|||
for section in row.sections or []:
|
||||
key = section.get("key")
|
||||
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())
|
||||
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(
|
||||
row,
|
||||
*,
|
||||
|
|
@ -21,9 +42,9 @@ def serialize_form(
|
|||
"interviewer_id": str(row.interviewer_id) if row.interviewer_id else None,
|
||||
"interviewer_name": interviewer_name,
|
||||
"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 {},
|
||||
"overall_score": row.overall_score,
|
||||
"overall_score": to_percent(row.overall_score),
|
||||
"recommendation": row.recommendation,
|
||||
"created_by": str(row.created_by) if row.created_by else None,
|
||||
"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.plugins import (
|
||||
FORM_DEFINITIONS,
|
||||
FORM_READY_STATUSES,
|
||||
FORM_TYPES,
|
||||
RECOMMENDATIONS,
|
||||
combined_summary,
|
||||
normalize_fields,
|
||||
normalize_sections,
|
||||
)
|
||||
from candidate_forms.serializers import (
|
||||
serialize_form,
|
||||
|
|
@ -18,10 +22,12 @@ from candidate_forms.serializers import (
|
|||
)
|
||||
from inbox.models import Inbox
|
||||
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.views import HistoryRecorder
|
||||
from job.job_post.models import JobPosts
|
||||
from users.models import Users
|
||||
from users.permissions import is_admin, is_hiring_manager
|
||||
|
||||
logger = logging.getLogger("candidate_forms")
|
||||
|
||||
|
|
@ -54,6 +60,35 @@ def _stage_value(status) -> str:
|
|||
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:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
|
@ -81,16 +116,22 @@ class CandidateForm:
|
|||
stage = _stage_value(
|
||||
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:
|
||||
inbox_id = None
|
||||
manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id)
|
||||
if manual is None:
|
||||
raise HTTPException(status_code=404, detail="Manual upload candidate not found")
|
||||
stage = _stage_value(manual.status)
|
||||
app_job = manual.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:
|
||||
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:
|
||||
post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id))
|
||||
if not post or post.is_deleted:
|
||||
|
|
@ -181,11 +222,27 @@ class CandidateForm:
|
|||
form_type=None,
|
||||
top=None,
|
||||
skip=0,
|
||||
current_user=None,
|
||||
):
|
||||
if form_type and form_type not in FORM_TYPES:
|
||||
raise HTTPException(
|
||||
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(
|
||||
self.session,
|
||||
form_id=form_id,
|
||||
|
|
@ -196,6 +253,14 @@ class CandidateForm:
|
|||
top=top,
|
||||
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
|
||||
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)}"
|
||||
)
|
||||
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:
|
||||
has_interview = False
|
||||
if inbox_id is not None:
|
||||
|
|
@ -237,6 +309,8 @@ class CandidateForm:
|
|||
if form_type != "requisition" and interviewer_id is None:
|
||||
interviewer_id = _user_id(current_user)
|
||||
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(
|
||||
self.session,
|
||||
|
|
@ -247,9 +321,10 @@ class CandidateForm:
|
|||
"job_post_id": job_post_id,
|
||||
"interviewer_id": interviewer_id,
|
||||
"form_date": form_date,
|
||||
"sections": payload.get("sections"),
|
||||
"fields": payload.get("fields"),
|
||||
"recommendation": payload.get("recommendation"),
|
||||
"sections": sections,
|
||||
"fields": fields,
|
||||
"overall_score": overall_score,
|
||||
"recommendation": _recommendation(form_type, payload.get("recommendation")),
|
||||
"created_by": _user_id(current_user),
|
||||
},
|
||||
)
|
||||
|
|
@ -270,6 +345,13 @@ class CandidateForm:
|
|||
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,
|
||||
)
|
||||
|
||||
fields = {}
|
||||
if "interviewer_id" in payload:
|
||||
|
|
@ -277,11 +359,13 @@ class CandidateForm:
|
|||
if "form_date" in payload:
|
||||
fields["form_date"] = _aware(payload.get("form_date"))
|
||||
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:
|
||||
fields["fields"] = payload.get("fields")
|
||||
fields["fields"] = _score_fields(row.form_type, payload.get("fields"))
|
||||
if "recommendation" in payload:
|
||||
fields["recommendation"] = payload.get("recommendation")
|
||||
fields["recommendation"] = _recommendation(row.form_type, payload.get("recommendation"))
|
||||
if not fields:
|
||||
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):
|
||||
_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)
|
||||
if not row:
|
||||
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):
|
||||
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:
|
||||
row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
|
||||
if not row:
|
||||
|
|
@ -339,6 +435,8 @@ class RequisitionForm:
|
|||
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
|
||||
return [serialize_requisition(r) for r in rows]
|
||||
|
||||
async def search(self, q, top=50):
|
||||
rows = await Requisition.search(self.session, q, top=top)
|
||||
async def search(self, q, top=50, job_post_id=None):
|
||||
rows = await Requisition.search(
|
||||
self.session, q, top=top, job_post_id=job_post_id,
|
||||
)
|
||||
return [serialize_requisition_option(r) for r in rows]
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
"refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8",
|
||||
"universe_domain": "googleapis.com",
|
||||
"account": "ahmed.mujtaba@utopiabrands.com",
|
||||
"token": "ya29.a0AdMD6Eg_6meQs84gTmiyhzZp7C-JlZeJU6-ECm6twwAcMqvfvRvyvs5LQGbAhHarHzZF-jiU-sicebJmxXIN4l6hNDXoaHcrojuhq--hj2oSBWojiEKaGIgLKPM8frdspz_wVANrwkFwpIKhN3RpWID9mJCt7N6IFaNrZtgStakdF0sVCKKVttE7qWK0vIvJT3HZHpVbaCgYKAX8SARASFQHGX2MiQvJqWStYQDgYFK4E6GJ9Zw0207",
|
||||
"expiry": "2026-08-31T09:52:09Z",
|
||||
"token": "ya29.a0AdMD6EgILeNb9UszC7bQJbAcqX709J5ky3eM8MEuQayGwhDStmfnR5t7o192x-FPdt53Q29rYL69zqYrgofUqpwxoI_sPjBsb0wrLqYDo6zwJMTx4P5svM4jZJd9nrXzUEyp3uI81e9DQ3z6lIDKtm6aUTPWQ3fm33i2hxJM7i-svhi3OwjnLtpOUHDyw--v8rQLPHdfaCgYKATkSARASFQHGX2MiXDnutGLllH9DnNCBUKWi4Q0207",
|
||||
"expiry": "2026-09-02T08:29:45Z",
|
||||
"quota_project_id": "hrms-ats-portal"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class Inbox(SQLModel, table=True):
|
|||
)
|
||||
|
||||
@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:
|
||||
from job.job_post.models import JobPosts
|
||||
qry=(
|
||||
|
|
@ -116,7 +116,12 @@ class Inbox(SQLModel, table=True):
|
|||
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)
|
||||
if limit is not None:
|
||||
qry=qry.limit(limit).offset(offset)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from job.history.views import HistoryRecorder
|
|||
from job.assignment.views import Assignment
|
||||
from job.cost.views import HiringCost
|
||||
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_assist.execute_agent import run_field_assist
|
||||
from job.job_post.export import build_jobs_workbook
|
||||
|
|
@ -236,9 +236,16 @@ async def fetch_users(
|
|||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
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)
|
||||
data=await service.get_users(role_id=role_id,top=top,skip=skip)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as 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."""
|
||||
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)
|
||||
total=await service.count_users(search=search,role_id=role_id)
|
||||
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as 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))
|
||||
|
||||
|
||||
@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")
|
||||
async def fetch_job_departments(
|
||||
active_only: bool = Query(False),
|
||||
|
|
@ -865,6 +919,25 @@ async def fetch_candidate_by_id(
|
|||
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")
|
||||
async def fetch_candidate(
|
||||
user_id:str=Query(None),
|
||||
|
|
@ -876,7 +949,9 @@ async def fetch_candidate(
|
|||
):
|
||||
try:
|
||||
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
|
||||
|
|
@ -999,7 +1074,7 @@ async def fetch_notes(
|
|||
):
|
||||
try:
|
||||
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)
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
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))
|
||||
|
||||
@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:
|
||||
from inbox.models import AtsResults
|
||||
from users.models import Users
|
||||
|
|
@ -111,7 +111,12 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
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)
|
||||
if limit is not None:
|
||||
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,
|
||||
"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,
|
||||
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.serializers import serialize_job_post
|
||||
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.candidate.plugins import extract_candidate_email
|
||||
from users.models import Users
|
||||
from users.permissions import is_hiring_manager
|
||||
from employment_agent.plugins import parse_phone
|
||||
|
||||
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","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:
|
||||
|
|
@ -697,14 +754,62 @@ class CandidateView:
|
|||
logger.exception("manual candidate rollback failed for %s",getattr(row,"id",None))
|
||||
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:
|
||||
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 mode must see every application for the candidate, not one page.
|
||||
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)
|
||||
if detail:
|
||||
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:
|
||||
return await self.attach_profile_detail(rows)
|
||||
# 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)
|
||||
if not manual:
|
||||
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)
|
||||
job_post=None
|
||||
if manual.job_post_id:
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
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.orm import aliased
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
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))
|
||||
status: str = Field(default="draft")
|
||||
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"})
|
||||
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
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)
|
||||
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
|
||||
async def list_departments(cls, session: AsyncSession, *, active_only: bool = False):
|
||||
"""Distinct non-empty departments on non-deleted job posts.
|
||||
|
|
@ -215,6 +401,39 @@ class JobPosts(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
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
|
||||
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
|
||||
"""Open requisitions per hiring manager, keyed by users.id."""
|
||||
|
|
@ -397,6 +616,7 @@ class JobPosts(SQLModel, table=True):
|
|||
return None
|
||||
row.is_deleted = True
|
||||
row.is_active = False
|
||||
row.requisition_id = None
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
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 —
|
||||
talent-pool filters key off it on attached job_posts.
|
||||
"""
|
||||
req = getattr(row, "requisition", None)
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"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_name": hiring_manager_name,
|
||||
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
||||
"requisition_title": req.position_title if req else None,
|
||||
"requisition_department": req.department if req else None,
|
||||
"applicant_count": applicant_count,
|
||||
"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,
|
||||
|
|
@ -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:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from job.job_post.plugins import (
|
|||
render_job_post,
|
||||
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()
|
||||
logger=logging.getLogger("job.job_post")
|
||||
|
|
@ -234,6 +234,28 @@ class JobPost:
|
|||
)
|
||||
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):
|
||||
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))
|
||||
if not req:
|
||||
raise HTTPException(status_code=404,detail="Requisition not found")
|
||||
held=await JobPosts.get_by_requisition_id(self.session, req.id)
|
||||
if held and str(held.id)!=str(existing.id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This requisition is already linked to a job post",
|
||||
)
|
||||
fields["requisition_id"]=req.id
|
||||
if "current_recruiter_id" in payload:
|
||||
raw=payload.get("current_recruiter_id")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from job.candidate.models import Notes
|
||||
from job.candidate.views import assert_manager_candidate_access
|
||||
from job.history.enums import HistoryEvent
|
||||
from job.history.views import HistoryRecorder
|
||||
from job.notes.serializers import serialize_note
|
||||
|
|
@ -14,17 +15,21 @@ class Note:
|
|||
async def _load(self,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:
|
||||
row=await self._load(note_id)
|
||||
if not row:
|
||||
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)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400,detail="note_id or user_id is required")
|
||||
uid=Notes._as_uuid(user_id)
|
||||
if uid is None:
|
||||
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)
|
||||
return [serialize_note(r) for r in rows]
|
||||
|
||||
|
|
@ -36,6 +41,9 @@ class Note:
|
|||
}
|
||||
if not fields["user_id"]:
|
||||
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)
|
||||
await HistoryRecorder(self.session).record(
|
||||
HistoryEvent.NOTE_CREATED.value,
|
||||
|
|
@ -53,6 +61,9 @@ class Note:
|
|||
before=await self._load(note_id)
|
||||
if not before:
|
||||
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 ""
|
||||
row=await Notes.update_note(self.session,note_id,fields)
|
||||
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 db_setup import get_session
|
||||
from role.models import Roles
|
||||
from role.models import EnumRoles, Roles
|
||||
from users.models import Users
|
||||
from users.plugins import decode_token
|
||||
from users.serializers import serialize_user
|
||||
|
|
@ -203,6 +203,37 @@ def _assert_vocabulary_complete() -> None:
|
|||
_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(
|
||||
granted: set[str] | list[str] | tuple[str, ...],
|
||||
*required: PermissionTag,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ server {
|
|||
}
|
||||
|
||||
# API-only prefixes (no SPA page at the bare path).
|
||||
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|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_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
|
@ -57,18 +57,39 @@ server {
|
|||
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/ {
|
||||
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;
|
||||
|
||||
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.
|
||||
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-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const SCREENS = {
|
|||
candidates: lazy(() => import('./screens/Candidates')),
|
||||
talentpool: lazy(() => import('./screens/TalentPool')),
|
||||
pipeline: lazy(() => import('./screens/Pipeline')),
|
||||
progress: lazy(() => import('./screens/Progress')),
|
||||
import: lazy(() => import('./screens/CvImport')),
|
||||
jobboard: lazy(() => import('./screens/JobBoard')),
|
||||
recruiterhub: lazy(() => import('./screens/RecruiterHub')),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import Talent from '../screens/Talent'
|
|||
import Tasks from '../screens/Tasks'
|
||||
import AiAssistant from '../screens/AiAssistant'
|
||||
import Interviews from '../screens/Interviews'
|
||||
import Requisitions from '../screens/Requisitions'
|
||||
import Assessments from '../screens/Assessments'
|
||||
import Offers from '../screens/Offers'
|
||||
import Managers from '../screens/Managers'
|
||||
|
|
@ -53,7 +54,7 @@ const SCREENS = {
|
|||
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
||||
talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard,
|
||||
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,
|
||||
aistudio: AiStudio, notifications: Notifications, rbac: Rbac,
|
||||
settings: Settings, help: Help,
|
||||
|
|
|
|||
|
|
@ -259,6 +259,15 @@ export function getByUserId(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. */
|
||||
export function toRows(res) {
|
||||
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 ?? [],
|
||||
optionalSkills: row.optional_skills ?? [],
|
||||
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 } })
|
||||
}
|
||||
|
||||
/** Searchable picker — GET /forms/requisition/search. `q` matches title or department. */
|
||||
export function search({ q, top } = {}) {
|
||||
/** Searchable picker — GET /forms/requisition/search. `q` matches title or department.
|
||||
* `jobPostId` keeps the job's current requisition in the list while editing. */
|
||||
export function search({ q, top, jobPostId } = {}) {
|
||||
return request('/forms/requisition/search', {
|
||||
params: { q: q || undefined, top },
|
||||
params: {
|
||||
q: q || undefined,
|
||||
top,
|
||||
job_post_id: jobPostId || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { NAV_GROUPS, ROUTES } from './routes'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { HIRING_MANAGER_NAV, isHiringManager } from '../auth/permissions'
|
||||
import Icon from '../ui/icons'
|
||||
import { BrandGlyph } from '../components/BrandMark'
|
||||
|
||||
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
|
||||
// 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 (
|
||||
<aside
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const ROUTES = [
|
|||
{ path: 'candidates', title: 'Candidates', icon: 'users', 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: 'progress', title: 'Progress', icon: 'trending-up', group: 'Workspace', permission: 'jobs.view' },
|
||||
|
||||
// --- Recruiting ---
|
||||
{ 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 ?? [])
|
||||
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],
|
||||
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
||||
statusHistory: (id) => ['jobs', 'status-history', id],
|
||||
stats: (p = {}) => ['jobs', 'stats', p],
|
||||
},
|
||||
talent: {
|
||||
all: () => ['talent'],
|
||||
|
|
@ -93,6 +94,7 @@ export const qk = {
|
|||
candidates: {
|
||||
all: () => ['candidates'],
|
||||
list: (p = {}) => ['candidates', 'list', p],
|
||||
managerList: (p = {}) => ['candidates', 'manager', p],
|
||||
count: (p = {}) => ['candidates', 'count', p],
|
||||
detail: (id) => ['candidates', 'detail', id],
|
||||
history: (id, p = {}) => ['candidates', 'history', id, p],
|
||||
|
|
@ -128,7 +130,7 @@ export const qk = {
|
|||
all: () => ['requisitions'],
|
||||
list: () => ['requisitions', 'list'],
|
||||
detail: (id) => ['requisitions', 'detail', id],
|
||||
search: (q = '') => ['requisitions', 'search', q],
|
||||
search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
|
||||
},
|
||||
interviews: {
|
||||
all: () => ['interviews'],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Alert from '../components/Alert'
|
|||
import Spinner from '../components/Spinner'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
|
||||
export default function Login() {
|
||||
const form = useFormState({ email: '', password: '' })
|
||||
|
|
@ -28,9 +29,12 @@ export default function Login() {
|
|||
form.setAlert(null)
|
||||
form.setBusy(true)
|
||||
try {
|
||||
await signIn(form.values.email.trim(), form.values.password)
|
||||
// In-SPA now: the old full page load out to /index.html#dashboard is gone.
|
||||
navigate(from, { replace: true })
|
||||
const res = await signIn(form.values.email.trim(), form.values.password)
|
||||
const role = res?.data?.role_name
|
||||
const dest = (from === '/dashboard' || from === '/') && isHiringManager({ role_name: role })
|
||||
? '/candidates'
|
||||
: from
|
||||
navigate(dest, { replace: true })
|
||||
} catch (err) {
|
||||
form.setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not sign in.') })
|
||||
form.setBusy(false)
|
||||
|
|
|
|||
|
|
@ -13,9 +13,8 @@
|
|||
friendly version. Forms attach to an application — the inbox row for email
|
||||
applicants, the manual_upload_candidate row for hand-added candidates.
|
||||
|
||||
Layout system: .hf-* classes in styles.css. Rated criteria render as the
|
||||
paper's own table (scale header, radio-dot cells, the SECTION AVERAGE foot);
|
||||
the score summary is a stat-tile row with the combined overall as the hero. */
|
||||
Layout system: .hf-* classes in styles.css. Rated criteria render as a
|
||||
25/50/75/100% grid; section and combined totals are percentages. */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
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 { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as formsApi from '../api/forms'
|
||||
import * as offersApi from '../api/offers'
|
||||
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_TIMINGS = ['Morning', 'Afternoon', 'Evening', 'Night']
|
||||
|
||||
|
|
@ -61,7 +77,8 @@ function useFormsWrite({ userId, mutationFn, success, onDone }) {
|
|||
}
|
||||
|
||||
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.
|
||||
const [seg, setSeg] = useState('interview_analysis')
|
||||
|
||||
|
|
@ -82,7 +99,7 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
queryKey: qk.forms.definitions(),
|
||||
queryFn: formsApi.definitions,
|
||||
enabled: hasApplication && unlocked,
|
||||
staleTime: Infinity,
|
||||
staleTime: 0,
|
||||
})
|
||||
const formsQuery = useQuery({
|
||||
queryKey: qk.forms.list(listParams),
|
||||
|
|
@ -93,7 +110,7 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
const offersQuery = useQuery({
|
||||
queryKey: qk.offers.list({ inboxId }),
|
||||
queryFn: () => offersApi.list({ inboxId }),
|
||||
enabled: Boolean(inboxId) && unlocked,
|
||||
enabled: Boolean(inboxId) && unlocked && !isManager,
|
||||
})
|
||||
|
||||
if (!hasApplication) {
|
||||
|
|
@ -135,9 +152,12 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
const summary = formsQuery.data?.summary ?? null
|
||||
const offers = offersQuery.data?.data ?? []
|
||||
// Spread into create payloads — exactly one key, matching the backend XOR.
|
||||
const link = inboxId
|
||||
const link = {
|
||||
...(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 = {
|
||||
interview_analysis: rows.some((r) => r.form_type === 'interview_analysis'),
|
||||
|
|
@ -147,7 +167,7 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
const segTabs = [
|
||||
{ key: 'interview_analysis', label: 'Interview Analysis' },
|
||||
{ key: 'cultural_fit', label: 'Cultural Fit' },
|
||||
{ key: 'offer', label: 'Offer' },
|
||||
...(!isManager ? [{ key: 'offer', label: 'Offer' }] : []),
|
||||
]
|
||||
|
||||
const evalCount = rows.filter(
|
||||
|
|
@ -185,7 +205,7 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
canEdit={can('interviews.edit')}
|
||||
/>
|
||||
)}
|
||||
{seg === 'offer' && (
|
||||
{seg === 'offer' && !isManager && (
|
||||
<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
|
||||
overall as the hero. Values are magnitudes on a fixed 1–4 scale, so each
|
||||
tile carries a thin single-hue meter; numbers stay in text ink. */
|
||||
overall as the hero. Ticks and averages are 25/50/75/100 percentages. */
|
||||
|
||||
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 (
|
||||
<div className={`hf-tile${hero ? ' hero' : ''}`}>
|
||||
<div className="hf-k" title={label}>{label}</div>
|
||||
<div className="hf-v">
|
||||
{value != null ? value : '—'}
|
||||
{value != null && <small>/ 4</small>}
|
||||
{pct != null ? pct : '—'}
|
||||
{pct != null && <small>%</small>}
|
||||
</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>}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -243,7 +262,7 @@ function fieldLabel(def, key) {
|
|||
}
|
||||
|
||||
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
|
||||
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 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 && (
|
||||
<Badge className="b-gray">
|
||||
{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. */
|
||||
function RatingTable({ section, defs, ratings, onRate }) {
|
||||
const average = sectionAverage(ratings)
|
||||
const points = Array.isArray(defs.rating_points) && defs.rating_points.length
|
||||
? defs.rating_points
|
||||
: RATING_POINTS
|
||||
return (
|
||||
<div className="hf-rate">
|
||||
<div className="hf-rate-head">
|
||||
<div>Criteria</div>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
{points.map((n) => (
|
||||
<div key={n}>
|
||||
<span className="hf-scale-full">{defs.rating_labels[String(n)]}</span>
|
||||
<span className="hf-scale-short" title={defs.rating_labels[String(n)]}>{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)]}>{formatPercent(n)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{section.criteria.map((c) => (
|
||||
<div className="hf-rate-row" key={c.key}>
|
||||
<div>{c.label}</div>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
{points.map((n) => (
|
||||
<div className="hf-rate-cell" key={n}>
|
||||
<button
|
||||
type="button"
|
||||
className={`hf-dot${ratings[c.key] === n ? ' on' : ''}`}
|
||||
aria-label={`${c.label}: ${defs.rating_labels[String(n)]}`}
|
||||
className={`hf-dot${Number(ratings[c.key]) === Number(n) ? ' on' : ''}`}
|
||||
aria-label={`${c.label}: ${defs.rating_labels[String(n)] ?? formatPercent(n)}`}
|
||||
onClick={() => onRate(c.key, n)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -311,7 +333,7 @@ function RatingTable({ section, defs, ratings, onRate }) {
|
|||
))}
|
||||
<div className="hf-rate-foot">
|
||||
<div>{section.average_label || 'Section average'}</div>
|
||||
<div className="hf-avg">{average ?? '—'}</div>
|
||||
<div className="hf-avg">{formatPercent(average) ?? '—'}</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 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 {
|
||||
|
|
@ -413,7 +435,10 @@ function EvaluationEditor({ formType, def, defs, row, initial, userId, link, liv
|
|||
const setRating = (sectionKey, critKey, value) =>
|
||||
setRatings((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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
TWO DATA MODES, selected by whether the caller passes a `userId`:
|
||||
|
|
@ -7,8 +7,8 @@
|
|||
the prototype did.
|
||||
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
|
||||
match verdict, documents, and the four child collections (interviews,
|
||||
notes, activity, feedback). The write tabs POST to their own endpoints
|
||||
match verdict, documents, and the child collections (interviews,
|
||||
notes, activity). The write tabs POST to their own endpoints
|
||||
and invalidate this one query, so the whole modal repaints from a single
|
||||
refetch. History is fetched separately (GET /candidate/history/fetch)
|
||||
when that tab opens — it is append-only and not part of the detail payload.
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
worse than showing none.
|
||||
|
||||
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). */
|
||||
|
||||
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 { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
|
|
@ -41,12 +42,11 @@ import CandidateFormsTab from './CandidateForms'
|
|||
import { companies, fmtDate, moneyK, pick } from '../data/seed'
|
||||
|
||||
/* Workflow order: learn (Overview, Resume, Documents) → interview (Interview,
|
||||
Forms, Feedback) → track (Notes, Activity) → audit (Timeline, History). */
|
||||
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History']
|
||||
Forms) → track (Notes, Activity) → audit (Timeline, History). */
|
||||
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', 'Timeline', 'History']
|
||||
// Forward progression for the live Advance button. Rejected has no next stage.
|
||||
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 REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
|
||||
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
||||
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
|
||||
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
|
||||
|
|
@ -126,10 +126,11 @@ export default function CandidateProfile({
|
|||
variant = 'modal',
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const { can } = useAuth()
|
||||
const [tab, setTab] = useState('Overview')
|
||||
const { can, user } = useAuth()
|
||||
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: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
|
||||
const isLive = Boolean(c.userId)
|
||||
const detail = useCandidateDetail(c.userId)
|
||||
|
|
@ -230,7 +231,6 @@ export default function CandidateProfile({
|
|||
Notes: live.notes?.length ?? 0,
|
||||
Activity: live.activity?.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
|
||||
|
|
@ -247,7 +247,7 @@ export default function CandidateProfile({
|
|||
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
|
||||
) : null
|
||||
|
||||
const actions = (
|
||||
const actions = isManager ? null : (
|
||||
<>
|
||||
<button
|
||||
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
|
||||
|
|
@ -338,7 +338,7 @@ export default function CandidateProfile({
|
|||
value={tab}
|
||||
onChange={setTab}
|
||||
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>
|
||||
|
||||
|
|
@ -605,37 +605,6 @@ export default function CandidateProfile({
|
|||
))}
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
|
|
@ -650,7 +619,7 @@ export default function CandidateProfile({
|
|||
<div className="cand-page-crumb">
|
||||
Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
|
||||
</div>
|
||||
<div className="cand-page-actions">{actions}</div>
|
||||
{actions && <div className="cand-page-actions">{actions}</div>}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-body">{body}</div>
|
||||
|
|
@ -1241,198 +1210,3 @@ function DocumentsTab({ rows, inboxId, manualUploadCandidateId }) {
|
|||
</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.
|
||||
|
||||
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 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 { Avatar, Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
import CandidateProfile from './CandidateProfile'
|
||||
import { useJobTitles } from './ScoredCandidateProfile'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
|
|
@ -106,7 +108,134 @@ const REFERRAL_RE = new RegExp(
|
|||
*/
|
||||
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() {
|
||||
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 qc = useQueryClient()
|
||||
const location = useLocation()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 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 { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { ApiError, friendlyAuthError } from '../lib/errors'
|
||||
import { fmtShort, money, initials as initialsOf, avatarColor } from '../data/seed'
|
||||
|
|
@ -159,6 +160,12 @@ function clock(d) {
|
|||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth()
|
||||
if (isHiringManager(user)) return <Navigate to="/candidates" replace />
|
||||
return <DashboardHome />
|
||||
}
|
||||
|
||||
function DashboardHome() {
|
||||
const navigate = useNavigate()
|
||||
const { user, can } = useAuth()
|
||||
const { toast } = useToast()
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ export default function Jobs() {
|
|||
},
|
||||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
setCreating(false)
|
||||
if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error')
|
||||
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
|
||||
// say so; a flat "create failed" toast would be wrong.
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
if (err?.status === 502) {
|
||||
setCreating(false)
|
||||
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),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
setEditing(null)
|
||||
toast('Job updated', 'success')
|
||||
},
|
||||
|
|
@ -181,6 +184,7 @@ export default function Jobs() {
|
|||
mutationFn: (id) => jobsApi.remove(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
setViewing(null)
|
||||
toast('Job deleted', 'success')
|
||||
},
|
||||
|
|
@ -516,12 +520,10 @@ function useRecruiterDirectory() {
|
|||
})
|
||||
}
|
||||
|
||||
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
function useRequisitionPicker(initialPicked = null, jobPostId = null) {
|
||||
const [reqQ, setReqQ] = useState('')
|
||||
const [debouncedReqQ, setDebouncedReqQ] = useState('')
|
||||
const [pickedReq, setPickedReq] = useState(null)
|
||||
const [pickedReq, setPickedReq] = useState(initialPicked)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedReqQ(reqQ.trim()), 250)
|
||||
|
|
@ -529,9 +531,9 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
}, [reqQ])
|
||||
|
||||
const requisitionsQuery = useQuery({
|
||||
queryKey: qk.requisitions.search(debouncedReqQ),
|
||||
queryKey: qk.requisitions.search(debouncedReqQ, jobPostId),
|
||||
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 : []
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
|
|
@ -552,6 +554,14 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
return rows
|
||||
}, [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({
|
||||
hiring_manager_id: '',
|
||||
current_recruiter_id: '',
|
||||
|
|
@ -918,7 +928,19 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
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({
|
||||
requisition_id: j.requisitionId || '',
|
||||
title: j.title || '',
|
||||
department: j.department || '',
|
||||
location: j.location || '',
|
||||
|
|
@ -971,6 +993,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
description: form.values.description.trim() || null,
|
||||
hiring_manager_id: form.values.hiring_manager_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() }}>
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>Requisition</label>
|
||||
<SearchSelect
|
||||
options={requisitionOptions}
|
||||
value={form.values.requisition_id}
|
||||
onChange={(id) => {
|
||||
form.setField('requisition_id', id)
|
||||
if (!id) {
|
||||
setPickedReq(null)
|
||||
return
|
||||
}
|
||||
const opt = requisitionOptions.find((o) => String(o.id) === String(id))
|
||||
if (opt) {
|
||||
setPickedReq(opt)
|
||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
||||
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
|
||||
}
|
||||
}}
|
||||
onQueryChange={setReqQ}
|
||||
placeholder="Search by job title or department…"
|
||||
disabled={busy}
|
||||
loading={requisitionsQuery.isPending && !requisitionsQuery.data}
|
||||
allowEmpty
|
||||
emptyLabel="No requisition"
|
||||
/>
|
||||
{requisitionsQuery.isError && (
|
||||
<p className="text-muted text-sm">Could not load requisitions.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<div className="field-label-row">
|
||||
<label>Title</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">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">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">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>
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
.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; }
|
||||
|
||||
/* Empty-state CTA on the hiring forms: full-size, and full-width on phones. */
|
||||
.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
|
||||
(Dashboard v2 grid, candidate page, hiring forms). Kept in ONE block
|
||||
|
|
|
|||
Loading…
Reference in New Issue