Candidate hiring forms (Annexure A/E/J), full-page profile, UX audit fixes
Deploy to S3 / deploy (push) Successful in 37s
Details
Deploy to S3 / deploy (push) Successful in 37s
Details
Backend: new candidate_forms domain (requisition, interview analysis, cultural fit) with XOR inbox/manual keys, server-recomputed section averages and combined summary, INTERVIEW-stage gate (409), history events, INTERVIEWS_* permissions + 008 RBAC seed; offers table gains the seven Annexure-J fields. Frontend: Forms tab in the candidate profile (paper-exact labels from /forms/definitions, rating tables, score summary tiles, completion dots); profile converted to a full page at /candidate/:userId opened from Candidates, Talent Pool and Pipeline; live Advance Stage now calls PATCH /candidate/stage; workflow-ordered tabs; responsive pass verified by headless-Edge screenshots at 375-2400px; Stars import crash fix in Interviews; Matching tab strip wraps on phones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>pull/24/head
parent
312c574896
commit
f9eb9f1f24
|
|
@ -64,3 +64,6 @@ tests/
|
|||
tests/**
|
||||
*/tests/**
|
||||
*/tests/**/*
|
||||
# Paper form source documents (Annexure A/E/J) — reference material, not code.
|
||||
# Root-anchored: backend/candidate_forms/ is the forms domain package and IS tracked.
|
||||
/candidate_forms/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from candidate_forms.plugins import definitions_payload
|
||||
from candidate_forms.views import CandidateForm
|
||||
from db_setup import get_session
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class FormCreate(BaseModel):
|
||||
form_type: str
|
||||
inbox_id: int | None = None
|
||||
manual_upload_candidate_id: str | None = None
|
||||
job_post_id: str | None = None
|
||||
interviewer_id: str | None = None
|
||||
form_date: datetime | None = None
|
||||
sections: list | None = None
|
||||
fields: dict | None = None
|
||||
recommendation: str | None = None
|
||||
|
||||
|
||||
class FormUpdate(BaseModel):
|
||||
interviewer_id: str | None = None
|
||||
form_date: datetime | None = None
|
||||
sections: list | None = None
|
||||
fields: dict | None = None
|
||||
recommendation: str | None = None
|
||||
|
||||
|
||||
@router.get("/forms/definitions")
|
||||
async def fetch_form_definitions(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW)),
|
||||
):
|
||||
try:
|
||||
return JSONResponse(content={"data": definitions_payload(), "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/forms/fetch")
|
||||
async def fetch_forms(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW)),
|
||||
form_id: str | None = Query(None),
|
||||
inbox_id: int | None = Query(None),
|
||||
manual_upload_candidate_id: str | None = Query(None),
|
||||
job_post_id: str | None = Query(None),
|
||||
form_type: str | None = Query(None),
|
||||
top: int | None = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
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,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"data": data, "summary": summary, "total": total, "status_code": 200}
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/forms/create")
|
||||
async def create_form(
|
||||
payload: FormCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_CREATE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = CandidateForm(session=session)
|
||||
data = await service.create_form(payload.model_dump(exclude_unset=True), current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/forms/update")
|
||||
async def update_form(
|
||||
payload: FormUpdate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_EDIT)),
|
||||
form_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = CandidateForm(session=session)
|
||||
data = await service.update_form(
|
||||
form_id, payload.model_dump(exclude_unset=True), current_user
|
||||
)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/forms/delete")
|
||||
async def delete_form(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_DELETE)),
|
||||
form_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = CandidateForm(session=session)
|
||||
data = await service.delete_form(form_id, current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, JSON, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class CandidateForms(SQLModel, table=True):
|
||||
"""One digitized hiring form (Annexure A requisition, or one of the two
|
||||
Annexure E evaluation forms). Exactly one of inbox_id /
|
||||
manual_upload_candidate_id links it to an application; `sections` holds the
|
||||
rated grids with server-recomputed averages, `fields` the scalar entries."""
|
||||
|
||||
__tablename__ = "candidate_forms"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
|
||||
manual_upload_candidate_id: uuid.UUID | None = Field(
|
||||
default=None, index=True, foreign_key="manual_upload_candidate.id"
|
||||
)
|
||||
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
||||
form_type: str = Field(index=True)
|
||||
interviewer_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
form_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
sections: list | None = Field(default=None, sa_type=JSON)
|
||||
fields: dict | None = Field(default=None, sa_type=JSON)
|
||||
overall_score: float | None = Field(default=None)
|
||||
recommendation: str | None = Field(default=None)
|
||||
created_by: uuid.UUID = Field(foreign_key="users.id")
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
is_deleted: bool = Field(default=False)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
if record_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_form_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def fetch_forms(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
form_id=None,
|
||||
inbox_id=None,
|
||||
manual_upload_candidate_id=None,
|
||||
job_post_id=None,
|
||||
form_type=None,
|
||||
top: int | None = None,
|
||||
skip: int = 0,
|
||||
):
|
||||
if form_id:
|
||||
row = await cls.get_form_by_id(session, form_id)
|
||||
if row is None:
|
||||
return [], 0
|
||||
return [row], 1
|
||||
|
||||
statement = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||
if inbox_id is not None:
|
||||
statement = statement.where(cls.inbox_id == int(inbox_id))
|
||||
if manual_upload_candidate_id is not None:
|
||||
uid = cls._as_uuid(manual_upload_candidate_id)
|
||||
if uid is None:
|
||||
return [], 0
|
||||
statement = statement.where(cls.manual_upload_candidate_id == uid)
|
||||
if job_post_id is not None:
|
||||
uid = cls._as_uuid(job_post_id)
|
||||
if uid is None:
|
||||
return [], 0
|
||||
statement = statement.where(cls.job_post_id == uid)
|
||||
if form_type:
|
||||
statement = statement.where(cls.form_type == form_type)
|
||||
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 skip:
|
||||
statement = statement.offset(skip)
|
||||
if top is not None:
|
||||
statement = statement.limit(top)
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
@classmethod
|
||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_form_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_form(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_form_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_form(cls, session: AsyncSession, record_id):
|
||||
row = await cls.get_form_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_deleted = True
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row
|
||||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
"""Pure helpers for the hiring forms domain — no FastAPI, no DB.
|
||||
|
||||
FORM_DEFINITIONS is the single authority for section/criterion/field keys AND
|
||||
their on-screen labels, which reproduce the paper annexures verbatim (Annexure A
|
||||
Employee Requisition Form, Annexure E Interview Evaluation Form). The frontend
|
||||
renders labels from /forms/definitions, and criterion labels are denormalized
|
||||
into every saved row so historical records survive future renames.
|
||||
"""
|
||||
|
||||
FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit")
|
||||
|
||||
RATING_MIN = 1
|
||||
RATING_MAX = 4
|
||||
RATING_LABELS = {
|
||||
1: "Below Average (1)",
|
||||
2: "Average (2)",
|
||||
3: "Good (3)",
|
||||
4: "Excellent (4)",
|
||||
}
|
||||
RATING_SCALE_NOTE = (
|
||||
"Rating Scale: 1 = Below Average | 2 = Average | 3 = Good | 4 = Excellent. "
|
||||
"Tick the box that applies for each criterion."
|
||||
)
|
||||
|
||||
RECOMMENDATIONS = (
|
||||
"selected",
|
||||
"hold",
|
||||
"next_round",
|
||||
"not_selected",
|
||||
"other_position",
|
||||
"offer_placement",
|
||||
)
|
||||
RECOMMENDATION_LABELS = {
|
||||
"selected": "Selected",
|
||||
"hold": "Hold for now",
|
||||
"next_round": "Shortlist for next round",
|
||||
"not_selected": "Not selected",
|
||||
"other_position": "Consider for other position",
|
||||
"offer_placement": "Offer Placement",
|
||||
}
|
||||
|
||||
# INTERVIEW onward; APPROVED is the legacy spelling the UI maps to Hired.
|
||||
FORM_READY_STATUSES = ("INTERVIEW", "OFFER", "HIRED", "APPROVED")
|
||||
|
||||
EMPLOYMENT_TYPES = ("permanent", "temporary", "contract", "internee")
|
||||
EMPLOYMENT_TYPE_LABELS = {
|
||||
"permanent": "Permanent",
|
||||
"temporary": "Temporary",
|
||||
"contract": "Contract",
|
||||
"internee": "Internee",
|
||||
}
|
||||
|
||||
_EVALUATION_HEADER_FIELDS = [
|
||||
{"key": "interviewer_name", "label": "Interviewer Name", "kind": "text"},
|
||||
{"key": "department", "label": "Department/Division", "kind": "text"},
|
||||
{"key": "position_title", "label": "Position Interviewed For", "kind": "text"},
|
||||
]
|
||||
|
||||
_EVALUATION_FOOTER_FIELDS = [
|
||||
{"key": "strengths", "label": "Key Strengths", "kind": "textarea"},
|
||||
{"key": "concerns", "label": "Main Concerns or Gaps", "kind": "textarea"},
|
||||
{
|
||||
"key": "overall_observation",
|
||||
"label": "Overall Observation of the Candidate",
|
||||
"kind": "textarea",
|
||||
},
|
||||
]
|
||||
|
||||
FORM_DEFINITIONS = {
|
||||
"interview_analysis": {
|
||||
"title": "Interview Analysis",
|
||||
"source": "Annexure E - Interview Evaluation Form",
|
||||
"scale_note": RATING_SCALE_NOTE,
|
||||
"sections": [
|
||||
{
|
||||
"key": "technical",
|
||||
"title": "TECHNICAL COMPETENCY ASSESSMENT",
|
||||
"average_label": "TECHNICAL SECTION AVERAGE",
|
||||
"criteria": [
|
||||
{"key": "core_job_knowledge", "label": "Core Job Knowledge & Domain Expertise"},
|
||||
{"key": "relevant_experience", "label": "Depth of Relevant Experience"},
|
||||
{"key": "problem_solving", "label": "Problem Solving & Analytical Reasoning"},
|
||||
{"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"},
|
||||
{"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "behavioral",
|
||||
"title": "BEHAVIORAL COMPETENCY ASSESSMENT",
|
||||
"average_label": "BEHAVIORAL SECTION AVERAGE",
|
||||
"criteria": [
|
||||
{"key": "communication", "label": "Communication & Clarity of Expression"},
|
||||
{"key": "active_listening", "label": "Active Listening & Comprehension"},
|
||||
{"key": "ownership", "label": "Ownership & Accountability"},
|
||||
{"key": "resilience", "label": "Resilience Under Pressure"},
|
||||
{"key": "learning_agility", "label": "Learning Agility & Coachability"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"fields": (
|
||||
_EVALUATION_HEADER_FIELDS
|
||||
+ [
|
||||
{"key": "summary", "label": "BRIEF SUMMARY OF THE CANDIDATE", "kind": "textarea"},
|
||||
{"key": "technical_note", "label": "Technical Competency — Notes", "kind": "text"},
|
||||
{"key": "behavioral_note", "label": "Behavioral Competency — Notes", "kind": "text"},
|
||||
]
|
||||
+ _EVALUATION_FOOTER_FIELDS
|
||||
),
|
||||
"has_recommendation": True,
|
||||
},
|
||||
"cultural_fit": {
|
||||
"title": "Cultural Fit",
|
||||
"source": "Annexure E - Interview Evaluation Form",
|
||||
"scale_note": RATING_SCALE_NOTE,
|
||||
"sections": [
|
||||
{
|
||||
"key": "cultural",
|
||||
"title": "CULTURAL FIT",
|
||||
"average_label": "CULTURAL FIT SECTION",
|
||||
"criteria": [
|
||||
{"key": "company_values", "label": "Alignment with Company Values"},
|
||||
{"key": "professionalism", "label": "Professionalism & Integrity"},
|
||||
{"key": "collaboration", "label": "Collaboration & Team Orientation"},
|
||||
{"key": "adaptability", "label": "Adaptability to Change"},
|
||||
{"key": "work_ethic", "label": "Work Ethic & Reliability"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"fields": (
|
||||
_EVALUATION_HEADER_FIELDS
|
||||
+ [{"key": "cultural_note", "label": "Cultural Fit — Notes", "kind": "text"}]
|
||||
+ _EVALUATION_FOOTER_FIELDS
|
||||
),
|
||||
"has_recommendation": True,
|
||||
},
|
||||
"requisition": {
|
||||
"title": "Employee Requisition",
|
||||
"source": "Annexure A - Employee Requisition Form",
|
||||
"header_note": "To: Human Resource Department",
|
||||
"sections": [],
|
||||
"fields": [
|
||||
{"key": "department", "label": "From: (Dept.)", "kind": "text"},
|
||||
{"key": "job_title", "label": "Job Title", "kind": "text"},
|
||||
{"key": "date_needed", "label": "Date Needed", "kind": "date"},
|
||||
{
|
||||
"key": "employment_type",
|
||||
"label": "Permanent / Temporary / Contract / Internee",
|
||||
"kind": "select",
|
||||
"options": list(EMPLOYMENT_TYPES),
|
||||
},
|
||||
{"key": "period_from", "label": "If not permanent, specify the period — From", "kind": "date"},
|
||||
{"key": "period_to", "label": "If not permanent, specify the period — To", "kind": "date"},
|
||||
{
|
||||
"key": "jd_available",
|
||||
"label": (
|
||||
"JD Available (JD is mandatory, TA team will not proceed with "
|
||||
"sourcing until JD is provided)"
|
||||
),
|
||||
"kind": "bool",
|
||||
},
|
||||
{"key": "is_replacement", "label": "IF A REPLACEMENT, COMPLETE THE FOLLOWING", "kind": "bool"},
|
||||
{"key": "replacement_employee", "label": "Employee to be replaced", "kind": "text"},
|
||||
{"key": "replacement_grade", "label": "Grade", "kind": "text"},
|
||||
{"key": "replacement_job_title", "label": "Job Title (replaced employee)", "kind": "text"},
|
||||
{"key": "replacement_date_separated", "label": "Date Separated", "kind": "date"},
|
||||
{
|
||||
"key": "headcount_justification",
|
||||
"label": "IN CASE OF NEW/ADDITIONAL HEADCOUNT PLEASE PROVIDE JUSTIFICATION",
|
||||
"kind": "textarea",
|
||||
},
|
||||
{"key": "proposed_budget", "label": "PROPOSE BUDGET", "kind": "text"},
|
||||
{"key": "recommended_grade", "label": "RECOMMENDED GRADE", "kind": "text"},
|
||||
{"key": "internal_recommendation", "label": "INCASE OF INTERNAL RECOMMENDATE", "kind": "bool"},
|
||||
{"key": "recommended_employee_name", "label": "EMPLOYEE NAME", "kind": "text"},
|
||||
{"key": "recommended_employee_department", "label": "EMPLOYEE DEPARTMENT", "kind": "text"},
|
||||
{"key": "initiated_by", "label": "Initiated By — Name", "kind": "text"},
|
||||
{"key": "initiated_date", "label": "Initiated By — Date", "kind": "date"},
|
||||
{"key": "recommended_by", "label": "Recommended By — Name (Director)", "kind": "text"},
|
||||
{"key": "recommended_date", "label": "Recommended By — Date", "kind": "date"},
|
||||
{"key": "approved_by", "label": "Approved By — Name (Director HR)", "kind": "text"},
|
||||
{"key": "approved_date", "label": "Approved By — Date", "kind": "date"},
|
||||
{"key": "vp_approved_by", "label": "Approved By — Name (VP/SVP)", "kind": "text"},
|
||||
{"key": "vp_approved_date", "label": "Approved By — Date (VP/SVP)", "kind": "date"},
|
||||
],
|
||||
"field_enums": {"employment_type": EMPLOYMENT_TYPES},
|
||||
"has_recommendation": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def definitions_payload() -> dict:
|
||||
"""The response body for GET /forms/definitions."""
|
||||
return {
|
||||
"form_types": list(FORM_TYPES),
|
||||
"forms": FORM_DEFINITIONS,
|
||||
"rating_labels": {str(k): v for k, v in RATING_LABELS.items()},
|
||||
"recommendations": list(RECOMMENDATIONS),
|
||||
"recommendation_labels": dict(RECOMMENDATION_LABELS),
|
||||
"employment_types": list(EMPLOYMENT_TYPES),
|
||||
"employment_type_labels": dict(EMPLOYMENT_TYPE_LABELS),
|
||||
"form_ready_statuses": list(FORM_READY_STATUSES),
|
||||
}
|
||||
|
||||
|
||||
def _coerce_rating(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
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}")
|
||||
return rating
|
||||
|
||||
|
||||
def _mean(values, digits=2):
|
||||
values = [v for v in values if v is not None]
|
||||
if not values:
|
||||
return None
|
||||
return round(sum(values) / len(values), digits)
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
definition = FORM_DEFINITIONS.get(form_type)
|
||||
if definition is None:
|
||||
raise ValueError(f"unknown form_type {form_type!r}")
|
||||
if not definition["sections"]:
|
||||
return None, None
|
||||
if sections is None:
|
||||
sections = []
|
||||
if not isinstance(sections, list):
|
||||
raise ValueError("sections must be a list")
|
||||
|
||||
known_sections = {s["key"]: s for s in definition["sections"]}
|
||||
submitted = {}
|
||||
for entry in sections:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("each section must be an object")
|
||||
key = entry.get("key")
|
||||
if key not in known_sections:
|
||||
raise ValueError(f"unknown section {key!r} for {form_type}")
|
||||
criteria = entry.get("criteria") or []
|
||||
if not isinstance(criteria, list):
|
||||
raise ValueError("section criteria must be a list")
|
||||
known_criteria = {c["key"] for c in known_sections[key]["criteria"]}
|
||||
ratings = {}
|
||||
for criterion in criteria:
|
||||
if not isinstance(criterion, dict):
|
||||
raise ValueError("each criterion must be an object")
|
||||
ckey = criterion.get("key")
|
||||
if ckey not in known_criteria:
|
||||
raise ValueError(f"unknown criterion {ckey!r} in section {key!r}")
|
||||
ratings[ckey] = _coerce_rating(criterion.get("rating"))
|
||||
submitted[key] = ratings
|
||||
|
||||
normalized = []
|
||||
section_averages = []
|
||||
for section_def in definition["sections"]:
|
||||
ratings = submitted.get(section_def["key"], {})
|
||||
criteria = [
|
||||
{
|
||||
"key": c["key"],
|
||||
"label": c["label"],
|
||||
"rating": ratings.get(c["key"]),
|
||||
}
|
||||
for c in section_def["criteria"]
|
||||
]
|
||||
average = _mean([c["rating"] for c in criteria])
|
||||
if average is not None:
|
||||
section_averages.append(average)
|
||||
normalized.append(
|
||||
{
|
||||
"key": section_def["key"],
|
||||
"title": section_def["title"],
|
||||
"criteria": criteria,
|
||||
"average": average,
|
||||
}
|
||||
)
|
||||
return normalized, _mean(section_averages)
|
||||
|
||||
|
||||
def normalize_fields(form_type: str, fields):
|
||||
"""Keep only the definition's field keys, validate enums, coerce booleans."""
|
||||
definition = FORM_DEFINITIONS.get(form_type)
|
||||
if definition is None:
|
||||
raise ValueError(f"unknown form_type {form_type!r}")
|
||||
if fields is None:
|
||||
return {}
|
||||
if not isinstance(fields, dict):
|
||||
raise ValueError("fields must be an object")
|
||||
|
||||
known = {f["key"]: f for f in definition["fields"]}
|
||||
enums = definition.get("field_enums", {})
|
||||
normalized = {}
|
||||
for key, value in fields.items():
|
||||
spec = known.get(key)
|
||||
if spec is None:
|
||||
continue
|
||||
if value in (None, ""):
|
||||
normalized[key] = None
|
||||
continue
|
||||
if key in enums:
|
||||
value = str(value).strip().lower()
|
||||
if value not in enums[key]:
|
||||
raise ValueError(f"{key} must be one of {', '.join(enums[key])}")
|
||||
elif spec["kind"] == "bool":
|
||||
if isinstance(value, str):
|
||||
value = value.strip().lower() in ("true", "yes", "1", "on")
|
||||
else:
|
||||
value = bool(value)
|
||||
else:
|
||||
value = str(value).strip() or None
|
||||
normalized[key] = value
|
||||
return normalized
|
||||
|
||||
|
||||
def combined_summary(rows):
|
||||
"""Annexure E's OVERALL SCORE SUMMARY across the two evaluation forms.
|
||||
|
||||
`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.
|
||||
"""
|
||||
latest = {}
|
||||
for row in rows:
|
||||
if row.form_type not in ("interview_analysis", "cultural_fit"):
|
||||
continue
|
||||
current = latest.get(row.form_type)
|
||||
if current is None or (row.created_at and current.created_at and row.created_at > current.created_at):
|
||||
latest[row.form_type] = row
|
||||
if not latest:
|
||||
return None
|
||||
|
||||
averages = {"technical": None, "behavioral": None, "cultural": None}
|
||||
for row in latest.values():
|
||||
for section in row.sections or []:
|
||||
key = section.get("key")
|
||||
if key in averages:
|
||||
averages[key] = section.get("average")
|
||||
|
||||
complete = all(v is not None for v in averages.values())
|
||||
return {
|
||||
"technical_avg": averages["technical"],
|
||||
"behavioral_avg": averages["behavioral"],
|
||||
"cultural_avg": averages["cultural"],
|
||||
"combined_overall": _mean(list(averages.values())) if complete else None,
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
def serialize_form(
|
||||
row,
|
||||
*,
|
||||
candidate_name=None,
|
||||
job_title=None,
|
||||
interviewer_name=None,
|
||||
created_by_name=None,
|
||||
) -> dict:
|
||||
"""`candidate_name` / `job_title` / user names come from one batched lookup
|
||||
in views — never a lazy per-row load."""
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"inbox_id": row.inbox_id,
|
||||
"manual_upload_candidate_id": (
|
||||
str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None
|
||||
),
|
||||
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||
"candidate_name": candidate_name,
|
||||
"job_title": job_title,
|
||||
"form_type": row.form_type,
|
||||
"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,
|
||||
"fields": dict(row.fields) if row.fields else {},
|
||||
"overall_score": row.overall_score,
|
||||
"recommendation": row.recommendation,
|
||||
"created_by": str(row.created_by) if row.created_by else None,
|
||||
"created_by_name": created_by_name,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
import logging
|
||||
import uuid
|
||||
from datetime import timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from candidate_forms.models import CandidateForms, _now
|
||||
from candidate_forms.plugins import (
|
||||
FORM_READY_STATUSES,
|
||||
FORM_TYPES,
|
||||
RECOMMENDATIONS,
|
||||
combined_summary,
|
||||
normalize_fields,
|
||||
normalize_sections,
|
||||
)
|
||||
from candidate_forms.serializers import serialize_form
|
||||
from inbox.models import Inbox
|
||||
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||
from job.history.enums import HistoryEvent
|
||||
from job.history.views import HistoryRecorder
|
||||
from job.job_post.models import JobPosts
|
||||
from users.models import Users
|
||||
|
||||
logger = logging.getLogger("candidate_forms")
|
||||
|
||||
|
||||
def _as_uuid(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _user_id(current_user):
|
||||
if not current_user or not current_user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
uid = _as_uuid(current_user["id"])
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid user id")
|
||||
return uid
|
||||
|
||||
|
||||
def _aware(value):
|
||||
if value is not None and getattr(value, "tzinfo", None) is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
def _stage_value(status) -> str:
|
||||
return str(getattr(status, "value", status) or "").upper()
|
||||
|
||||
|
||||
class CandidateForm:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def _validate_link(self, payload):
|
||||
"""Exactly one of inbox_id / manual_upload_candidate_id; both rows must
|
||||
exist. Returns (inbox_id, manual_id, job_post_id, current_stage)."""
|
||||
inbox_id = payload.get("inbox_id")
|
||||
manual_id = _as_uuid(payload.get("manual_upload_candidate_id"))
|
||||
has_inbox = inbox_id is not None
|
||||
has_manual = manual_id is not None
|
||||
if has_inbox == has_manual:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Exactly one of inbox_id or manual_upload_candidate_id is required",
|
||||
)
|
||||
if has_inbox:
|
||||
try:
|
||||
inbox_id = int(inbox_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=422, detail="Invalid inbox_id")
|
||||
link = await Inbox.get_inbox_with_message(self.session, inbox_id)
|
||||
if link is None:
|
||||
raise HTTPException(status_code=404, detail="Inbox record not found")
|
||||
stage = _stage_value(
|
||||
link.messages.application_status 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)
|
||||
|
||||
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 not None:
|
||||
post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id))
|
||||
if not post or post.is_deleted:
|
||||
raise HTTPException(status_code=404, detail="Job post not found")
|
||||
return inbox_id, manual_id, job_post_id, stage
|
||||
|
||||
def _normalize_payload(self, form_type, payload):
|
||||
"""Shared create/update normalization. Returns the writable fields dict
|
||||
for the keys present in `payload`."""
|
||||
fields = {}
|
||||
if "sections" in payload:
|
||||
try:
|
||||
sections, overall = normalize_sections(form_type, payload.get("sections"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
fields["sections"] = sections
|
||||
fields["overall_score"] = overall
|
||||
if "fields" in payload:
|
||||
try:
|
||||
fields["fields"] = normalize_fields(form_type, payload.get("fields"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
if "recommendation" in payload:
|
||||
recommendation = payload.get("recommendation") or None
|
||||
if recommendation is not None and recommendation not in RECOMMENDATIONS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
|
||||
)
|
||||
fields["recommendation"] = recommendation
|
||||
if "interviewer_id" in payload:
|
||||
interviewer_id = _as_uuid(payload.get("interviewer_id"))
|
||||
if payload.get("interviewer_id") and interviewer_id is None:
|
||||
raise HTTPException(status_code=422, detail="Invalid interviewer_id")
|
||||
fields["interviewer_id"] = interviewer_id
|
||||
if "form_date" in payload:
|
||||
fields["form_date"] = _aware(payload.get("form_date"))
|
||||
return fields
|
||||
|
||||
async def _context_maps(self, rows):
|
||||
inbox_ids = [r.inbox_id for r in rows if r.inbox_id is not None]
|
||||
manual_ids = [r.manual_upload_candidate_id for r in rows if r.manual_upload_candidate_id]
|
||||
job_ids = [r.job_post_id for r in rows if r.job_post_id]
|
||||
|
||||
inbox_by_id = {}
|
||||
if inbox_ids:
|
||||
result = await self.session.execute(
|
||||
select(Inbox)
|
||||
.options(selectinload(Inbox.messages), selectinload(Inbox.user))
|
||||
.where(Inbox.id.in_(inbox_ids))
|
||||
)
|
||||
inbox_by_id = {row.id: row for row in result.scalars().all()}
|
||||
for row in inbox_by_id.values():
|
||||
msg = row.messages
|
||||
if msg is not None and msg.assigned_job_post_id:
|
||||
job_ids.append(msg.assigned_job_post_id)
|
||||
|
||||
manual_by_id = {}
|
||||
if manual_ids:
|
||||
result = await self.session.execute(
|
||||
select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids))
|
||||
)
|
||||
manual_by_id = {row.id: row for row in result.scalars().all()}
|
||||
for row in manual_by_id.values():
|
||||
if row.job_post_id:
|
||||
job_ids.append(row.job_post_id)
|
||||
|
||||
jobs_by_id = {}
|
||||
uids = [j for j in set(job_ids) if j]
|
||||
if uids:
|
||||
result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids)))
|
||||
jobs_by_id = {row.id: row for row in result.scalars().all()}
|
||||
|
||||
user_ids = {r.interviewer_id for r in rows if r.interviewer_id}
|
||||
user_ids |= {r.created_by for r in rows if r.created_by}
|
||||
users_by_id = {}
|
||||
if user_ids:
|
||||
result = await self.session.execute(
|
||||
select(Users.id, Users.name).where(Users.id.in_(user_ids))
|
||||
)
|
||||
users_by_id = {uid: name for uid, name in result.all()}
|
||||
return inbox_by_id, manual_by_id, jobs_by_id, users_by_id
|
||||
|
||||
def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id):
|
||||
candidate_name = None
|
||||
job_title = None
|
||||
if row.job_post_id and row.job_post_id in jobs_by_id:
|
||||
job_title = jobs_by_id[row.job_post_id].title
|
||||
if row.inbox_id is not None:
|
||||
link = inbox_by_id.get(row.inbox_id)
|
||||
if link is not None:
|
||||
if link.user is not None:
|
||||
candidate_name = link.user.name
|
||||
msg = link.messages
|
||||
if job_title is None and msg is not None and msg.assigned_job_post_id:
|
||||
job = jobs_by_id.get(msg.assigned_job_post_id)
|
||||
if job is not None:
|
||||
job_title = job.title
|
||||
if row.manual_upload_candidate_id:
|
||||
manual = manual_by_id.get(row.manual_upload_candidate_id)
|
||||
if manual is not None:
|
||||
candidate_name = candidate_name or manual.candidate_name or None
|
||||
if job_title is None and manual.job_post_id:
|
||||
job = jobs_by_id.get(manual.job_post_id)
|
||||
if job is not None:
|
||||
job_title = job.title
|
||||
return candidate_name, job_title
|
||||
|
||||
async def _serialize_rows(self, rows):
|
||||
inbox_by_id, manual_by_id, jobs_by_id, users_by_id = await self._context_maps(rows)
|
||||
out = []
|
||||
for row in rows:
|
||||
name, title = self._labels(row, inbox_by_id, manual_by_id, jobs_by_id)
|
||||
out.append(
|
||||
serialize_form(
|
||||
row,
|
||||
candidate_name=name,
|
||||
job_title=title,
|
||||
interviewer_name=users_by_id.get(row.interviewer_id),
|
||||
created_by_name=users_by_id.get(row.created_by),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
async def get_forms(
|
||||
self,
|
||||
form_id=None,
|
||||
inbox_id=None,
|
||||
manual_upload_candidate_id=None,
|
||||
job_post_id=None,
|
||||
form_type=None,
|
||||
top=None,
|
||||
skip=0,
|
||||
):
|
||||
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)}"
|
||||
)
|
||||
rows, total = await CandidateForms.fetch_forms(
|
||||
self.session,
|
||||
form_id=form_id,
|
||||
inbox_id=inbox_id,
|
||||
manual_upload_candidate_id=manual_upload_candidate_id,
|
||||
job_post_id=job_post_id,
|
||||
form_type=form_type,
|
||||
top=top,
|
||||
skip=skip or 0,
|
||||
)
|
||||
|
||||
summary = None
|
||||
if inbox_id is not None or manual_upload_candidate_id is not None:
|
||||
if form_type:
|
||||
# The filtered fetch may not include both evaluation forms.
|
||||
summary_rows, _ = await CandidateForms.fetch_forms(
|
||||
self.session,
|
||||
inbox_id=inbox_id,
|
||||
manual_upload_candidate_id=manual_upload_candidate_id,
|
||||
)
|
||||
else:
|
||||
summary_rows = rows
|
||||
summary = combined_summary(summary_rows)
|
||||
return await self._serialize_rows(rows), summary, total
|
||||
|
||||
async def create_form(self, payload, current_user):
|
||||
form_type = (payload.get("form_type") or "").strip()
|
||||
if form_type not in FORM_TYPES:
|
||||
raise HTTPException(
|
||||
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)
|
||||
if stage not in FORM_READY_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Forms unlock at the Interview stage — this candidate is at "
|
||||
f"{stage or 'Shortlist'}"
|
||||
),
|
||||
)
|
||||
|
||||
fields = {
|
||||
"inbox_id": inbox_id,
|
||||
"manual_upload_candidate_id": manual_id,
|
||||
"job_post_id": job_post_id,
|
||||
"form_type": form_type,
|
||||
"created_by": _user_id(current_user),
|
||||
}
|
||||
fields.update(
|
||||
self._normalize_payload(
|
||||
form_type,
|
||||
{
|
||||
key: payload.get(key)
|
||||
for key in ("sections", "fields", "recommendation", "interviewer_id", "form_date")
|
||||
},
|
||||
)
|
||||
)
|
||||
if form_type != "requisition" and fields.get("interviewer_id") is None:
|
||||
fields["interviewer_id"] = _user_id(current_user)
|
||||
if fields.get("form_date") is None:
|
||||
fields["form_date"] = _now()
|
||||
|
||||
row = await CandidateForms.insert_form(self.session, fields)
|
||||
await HistoryRecorder(self.session).record(
|
||||
HistoryEvent.FORM_CREATED,
|
||||
current_user=current_user,
|
||||
inbox_id=inbox_id,
|
||||
manual_upload_candidate_id=manual_id,
|
||||
entity_type="candidate_form",
|
||||
entity_id=row.id,
|
||||
to_value=form_type,
|
||||
commit=True,
|
||||
)
|
||||
return (await self._serialize_rows([row]))[0]
|
||||
|
||||
async def update_form(self, form_id, payload, 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")
|
||||
|
||||
fields = self._normalize_payload(row.form_type, payload)
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
updated = await CandidateForms.update_form(self.session, form_id, fields)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
await HistoryRecorder(self.session).record(
|
||||
HistoryEvent.FORM_UPDATED,
|
||||
current_user=current_user,
|
||||
inbox_id=updated.inbox_id,
|
||||
manual_upload_candidate_id=updated.manual_upload_candidate_id,
|
||||
entity_type="candidate_form",
|
||||
entity_id=updated.id,
|
||||
to_value=updated.form_type,
|
||||
commit=True,
|
||||
)
|
||||
return (await self._serialize_rows([updated]))[0]
|
||||
|
||||
async def delete_form(self, form_id, current_user):
|
||||
_user_id(current_user)
|
||||
row = await CandidateForms.soft_delete_form(self.session, form_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
return {"id": str(row.id), "deleted": True}
|
||||
|
|
@ -18,3 +18,5 @@ class HistoryEvent(str, Enum):
|
|||
CANDIDATE_IMPORTED = "candidate.imported"
|
||||
DOCUMENT_UPLOADED = "document.uploaded"
|
||||
ATS_SCORED = "ats.scored"
|
||||
FORM_CREATED = "form.created"
|
||||
FORM_UPDATED = "form.updated"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from saved_search.app import router as saved_search_router
|
|||
from search.app import router as search_router
|
||||
from interview.app import router as interview_router
|
||||
from talent.app import router as talent_router
|
||||
from candidate_forms.app import router as candidate_forms_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
||||
logger=logging.getLogger("main")
|
||||
|
|
@ -106,3 +107,4 @@ app.include_router(saved_search_router)
|
|||
app.include_router(search_router)
|
||||
app.include_router(interview_router)
|
||||
app.include_router(talent_router)
|
||||
app.include_router(candidate_forms_router)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
-- 008_hiring_forms_rbac.sql
|
||||
-- Manual one-shot: a `hiring_forms` bundle granting interviews.create/edit/delete
|
||||
-- so staff roles can fill and amend the digitized hiring forms (Annexure A
|
||||
-- requisition, Annexure E interview analysis + cultural fit) served by the
|
||||
-- candidate_forms domain. The interviews.* tags themselves were seeded by 001;
|
||||
-- the analytics_dashboard bundle only carries interviews.view, which is why the
|
||||
-- write tags need this bundle. Mirrors 007's idempotent pattern; applied
|
||||
-- automatically at startup by alembic_setup.run_manual_sql() and recorded in
|
||||
-- manual_migrations.
|
||||
--
|
||||
-- Users must log in again after this applies — permissions are resolved from
|
||||
-- the DB per request, but the frontend caches the list from /users/me.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Bundle holding the interviews write tags
|
||||
-- =============================================================================
|
||||
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||
SELECT
|
||||
'hiring_forms',
|
||||
'Fill and amend candidate hiring forms (requisition, interview analysis, cultural fit)',
|
||||
(
|
||||
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||
FROM app.permission_tags
|
||||
WHERE is_deleted = false
|
||||
AND tag_name IN ('interviews.create', 'interviews.edit', 'interviews.delete')
|
||||
),
|
||||
true,
|
||||
NOW(),
|
||||
NOW(),
|
||||
true,
|
||||
false
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM app.permissions WHERE name = 'hiring_forms'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Attach the bundle to the staff roles (idempotent; same role list as 007)
|
||||
-- =============================================================================
|
||||
UPDATE app.roles r
|
||||
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
||||
updated_at = NOW()
|
||||
FROM app.permissions p
|
||||
WHERE p.name = 'hiring_forms'
|
||||
AND r.role_name IN (
|
||||
'system_administrator',
|
||||
'hr_administrator',
|
||||
'recruiter',
|
||||
'hiring_manager',
|
||||
'department_head',
|
||||
'ceo'
|
||||
)
|
||||
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||
|
|
@ -27,6 +27,13 @@ class OfferCreate(BaseModel):
|
|||
equity_instrument: str | None = None
|
||||
start_date: datetime | None = None
|
||||
expiry_date: datetime | None = None
|
||||
cadre: str | None = None
|
||||
gross_salary_in_words: str | None = None
|
||||
subsidized_services: str | None = None
|
||||
probation_period: str | None = None
|
||||
notice_period: str | None = None
|
||||
work_location: str | None = None
|
||||
work_timings: str | None = None
|
||||
change_reason: str | None = None
|
||||
|
||||
|
||||
|
|
@ -41,6 +48,13 @@ class OfferUpdate(BaseModel):
|
|||
equity_instrument: str | None = None
|
||||
start_date: datetime | None = None
|
||||
expiry_date: datetime | None = None
|
||||
cadre: str | None = None
|
||||
gross_salary_in_words: str | None = None
|
||||
subsidized_services: str | None = None
|
||||
probation_period: str | None = None
|
||||
notice_period: str | None = None
|
||||
work_location: str | None = None
|
||||
work_timings: str | None = None
|
||||
sent_at: datetime | None = None
|
||||
responded_at: datetime | None = None
|
||||
closed_at: datetime | None = None
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ class Offers(SQLModel, table=True):
|
|||
annual_bonus_pct: float | None = Field(default=None)
|
||||
equity_units: int | None = Field(default=None)
|
||||
equity_instrument: str | None = Field(default=None)
|
||||
# Annexure J (offer email format) fields.
|
||||
cadre: str | None = Field(default=None)
|
||||
gross_salary_in_words: str | None = Field(default=None)
|
||||
subsidized_services: str | None = Field(default=None)
|
||||
probation_period: str | None = Field(default=None)
|
||||
notice_period: str | None = Field(default=None)
|
||||
work_location: str | None = Field(default=None)
|
||||
work_timings: str | None = Field(default=None)
|
||||
start_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
expiry_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
def non_validation_values():
|
||||
fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct",
|
||||
"equity_units","equity_instrument","start_date","expiry_date")
|
||||
"equity_units","equity_instrument","start_date","expiry_date",
|
||||
"cadre","gross_salary_in_words","subsidized_services","probation_period",
|
||||
"notice_period","work_location","work_timings")
|
||||
return fields
|
||||
|
|
@ -12,6 +12,13 @@ def serialize_offer(row) -> dict:
|
|||
"annual_bonus_pct": row.annual_bonus_pct,
|
||||
"equity_units": row.equity_units,
|
||||
"equity_instrument": row.equity_instrument,
|
||||
"cadre": row.cadre,
|
||||
"gross_salary_in_words": row.gross_salary_in_words,
|
||||
"subsidized_services": row.subsidized_services,
|
||||
"probation_period": row.probation_period,
|
||||
"notice_period": row.notice_period,
|
||||
"work_location": row.work_location,
|
||||
"work_timings": row.work_timings,
|
||||
"start_date": row.start_date.isoformat() if row.start_date else None,
|
||||
"expiry_date": row.expiry_date.isoformat() if row.expiry_date else None,
|
||||
"sent_at": row.sent_at.isoformat() if row.sent_at else None,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@ class Offer:
|
|||
"status","base_salary","currency","salary_period","signing_bonus","annual_bonus_pct",
|
||||
"equity_units","equity_instrument","start_date","expiry_date","sent_at",
|
||||
"responded_at","closed_at","issued_by","inbox_id","job_post_id","candidate_user_id",
|
||||
"cadre","gross_salary_in_words","subsidized_services","probation_period",
|
||||
"notice_period","work_location","work_timings",
|
||||
):
|
||||
if key not in payload:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
"""Hermetic tests for the candidate_forms pure logic (plugins.py) — no DB, no
|
||||
FastAPI. The rated-section math, unknown-key handling, and the Annexure E
|
||||
combined summary are the parts a typo would silently corrupt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from candidate_forms.plugins import (
|
||||
FORM_DEFINITIONS,
|
||||
FORM_READY_STATUSES,
|
||||
combined_summary,
|
||||
definitions_payload,
|
||||
normalize_fields,
|
||||
normalize_sections,
|
||||
)
|
||||
|
||||
|
||||
def _sections(form_type: str, ratings_by_section: dict[str, dict[str, int | None]]):
|
||||
return [
|
||||
{"key": key, "criteria": [{"key": ck, "rating": rv} for ck, rv in ratings.items()]}
|
||||
for key, ratings in ratings_by_section.items()
|
||||
]
|
||||
|
||||
|
||||
class TestNormalizeSections:
|
||||
def test_recomputes_averages_and_overall(self):
|
||||
sections = _sections(
|
||||
"interview_analysis",
|
||||
{
|
||||
"technical": {"core_job_knowledge": 4, "relevant_experience": 3},
|
||||
"behavioral": {"communication": 2},
|
||||
},
|
||||
)
|
||||
normalized, overall = normalize_sections("interview_analysis", sections)
|
||||
by_key = {s["key"]: s for s in normalized}
|
||||
assert by_key["technical"]["average"] == 3.5
|
||||
assert by_key["behavioral"]["average"] == 2
|
||||
assert overall == 2.75
|
||||
|
||||
def test_client_sent_averages_are_discarded(self):
|
||||
sections = _sections("cultural_fit", {"cultural": {"company_values": 4}})
|
||||
sections[0]["average"] = 1.0 # lying client
|
||||
normalized, overall = normalize_sections("cultural_fit", sections)
|
||||
assert normalized[0]["average"] == 4
|
||||
assert overall == 4
|
||||
|
||||
def test_emits_every_definition_criterion_with_labels(self):
|
||||
normalized, overall = normalize_sections("interview_analysis", [])
|
||||
assert [s["key"] for s in normalized] == ["technical", "behavioral"]
|
||||
technical = normalized[0]
|
||||
assert len(technical["criteria"]) == 5
|
||||
assert technical["criteria"][0]["label"] == "Core Job Knowledge & Domain Expertise"
|
||||
assert technical["average"] is None
|
||||
assert overall is None
|
||||
|
||||
def test_unknown_section_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
normalize_sections("cultural_fit", _sections("cultural_fit", {"technical": {}}))
|
||||
|
||||
def test_unknown_criterion_rejected(self):
|
||||
bad = _sections("cultural_fit", {"cultural": {"made_up": 3}})
|
||||
with pytest.raises(ValueError):
|
||||
normalize_sections("cultural_fit", bad)
|
||||
|
||||
@pytest.mark.parametrize("rating", [0, 5, -1, "high", 3.5])
|
||||
def test_out_of_range_ratings_rejected(self, rating):
|
||||
bad = _sections("cultural_fit", {"cultural": {"company_values": rating}})
|
||||
with pytest.raises(ValueError):
|
||||
normalize_sections("cultural_fit", bad)
|
||||
|
||||
def test_string_and_null_ratings_coerced(self):
|
||||
sections = _sections(
|
||||
"cultural_fit", {"cultural": {"company_values": "3", "professionalism": None}}
|
||||
)
|
||||
normalized, _ = normalize_sections("cultural_fit", sections)
|
||||
ratings = {c["key"]: c["rating"] for c in normalized[0]["criteria"]}
|
||||
assert ratings["company_values"] == 3
|
||||
assert ratings["professionalism"] is None
|
||||
|
||||
def test_requisition_has_no_sections(self):
|
||||
assert normalize_sections("requisition", None) == (None, None)
|
||||
|
||||
|
||||
class TestNormalizeFields:
|
||||
def test_unknown_keys_dropped_and_bools_coerced(self):
|
||||
out = normalize_fields(
|
||||
"requisition",
|
||||
{"department": " IT ", "jd_available": "Yes", "bogus": "x", "is_replacement": False},
|
||||
)
|
||||
assert out == {"department": "IT", "jd_available": True, "is_replacement": False}
|
||||
|
||||
def test_employment_type_enum_enforced(self):
|
||||
assert normalize_fields("requisition", {"employment_type": "Contract"}) == {
|
||||
"employment_type": "contract"
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
normalize_fields("requisition", {"employment_type": "freelance"})
|
||||
|
||||
def test_evaluation_note_fields_exist(self):
|
||||
out = normalize_fields(
|
||||
"interview_analysis", {"technical_note": "solid", "behavioral_note": "calm"}
|
||||
)
|
||||
assert out == {"technical_note": "solid", "behavioral_note": "calm"}
|
||||
assert normalize_fields("cultural_fit", {"cultural_note": "fits"}) == {
|
||||
"cultural_note": "fits"
|
||||
}
|
||||
|
||||
|
||||
class TestCombinedSummary:
|
||||
def _row(self, form_type, day, ratings_by_section):
|
||||
sections, _ = normalize_sections(form_type, _sections(form_type, ratings_by_section))
|
||||
return SimpleNamespace(
|
||||
form_type=form_type, created_at=datetime(2026, 1, day), sections=sections
|
||||
)
|
||||
|
||||
def test_combined_needs_all_three_sections(self):
|
||||
ia = self._row(
|
||||
"interview_analysis",
|
||||
1,
|
||||
{"technical": {"core_job_knowledge": 4}, "behavioral": {"communication": 2}},
|
||||
)
|
||||
assert combined_summary([ia])["combined_overall"] is None
|
||||
|
||||
cf = self._row("cultural_fit", 2, {"cultural": {"company_values": 3}})
|
||||
summary = combined_summary([ia, cf])
|
||||
assert summary == {
|
||||
"technical_avg": 4,
|
||||
"behavioral_avg": 2,
|
||||
"cultural_avg": 3,
|
||||
"combined_overall": 3.0,
|
||||
}
|
||||
|
||||
def test_latest_row_per_type_wins(self):
|
||||
old = self._row("cultural_fit", 1, {"cultural": {"company_values": 1}})
|
||||
new = self._row("cultural_fit", 5, {"cultural": {"company_values": 4}})
|
||||
assert combined_summary([old, new])["cultural_avg"] == 4
|
||||
|
||||
def test_no_evaluations_returns_none(self):
|
||||
req = SimpleNamespace(form_type="requisition", created_at=datetime(2026, 1, 1), sections=None)
|
||||
assert combined_summary([req]) is None
|
||||
assert combined_summary([]) is None
|
||||
|
||||
|
||||
class TestDefinitions:
|
||||
def test_paper_parity_criterion_counts(self):
|
||||
ia = FORM_DEFINITIONS["interview_analysis"]
|
||||
cf = FORM_DEFINITIONS["cultural_fit"]
|
||||
assert [len(s["criteria"]) for s in ia["sections"]] == [5, 5]
|
||||
assert [len(s["criteria"]) for s in cf["sections"]] == [5]
|
||||
|
||||
def test_stage_gate_vocabulary(self):
|
||||
assert set(FORM_READY_STATUSES) == {"INTERVIEW", "OFFER", "HIRED", "APPROVED"}
|
||||
|
||||
def test_payload_is_json_shaped(self):
|
||||
payload = definitions_payload()
|
||||
assert set(payload["form_types"]) == {"requisition", "interview_analysis", "cultural_fit"}
|
||||
assert payload["recommendation_labels"]["next_round"] == "Shortlist for next round"
|
||||
assert payload["rating_labels"]["1"] == "Below Average (1)"
|
||||
|
|
@ -23,8 +23,8 @@
|
|||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||
<script type="module" crossorigin src="/assets/index-DoHV5rbL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BoTJFWhm.css">
|
||||
<script type="module" crossorigin src="/assets/index-CsixWp5R.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index--H0MdQBQ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ const SCREENS = {
|
|||
help: lazy(() => import('./screens/Help')),
|
||||
}
|
||||
|
||||
// Detail pages live outside the ROUTES table (no sidebar entry, parameterized path).
|
||||
const CandidatePage = lazy(() => import('./screens/CandidatePage'))
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
|
|
@ -82,6 +85,14 @@ export default function App() {
|
|||
/>
|
||||
)
|
||||
})}
|
||||
<Route
|
||||
path="/candidate/:userId"
|
||||
element={
|
||||
<RequireAuth permission="candidates.view">
|
||||
<CandidatePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/* ============================================================
|
||||
forms.js — backend/candidate_forms/app.py.
|
||||
|
||||
The digitized hiring forms: Annexure A (Employee Requisition), and the two
|
||||
halves of Annexure E — Interview Analysis (technical + behavioral) and
|
||||
Cultural Fit. Dual-key like assessments: exactly one of inbox_id /
|
||||
manual_upload_candidate_id.
|
||||
|
||||
Permissioned with the interviews module tags (interviews.view to read,
|
||||
interviews.create to fill, interviews.edit to amend). Creating is
|
||||
stage-gated SERVER-side: the application must be at INTERVIEW / OFFER /
|
||||
HIRED (or legacy APPROVED), else 409 — the UI hint mirrors, never replaces,
|
||||
that rule.
|
||||
|
||||
Field and criterion labels come from GET /forms/definitions, which is the
|
||||
single authority for the paper forms' exact wording — do not hardcode
|
||||
labels in components.
|
||||
============================================================ */
|
||||
|
||||
export const FORM_TYPES = ['requisition', 'interview_analysis', 'cultural_fit']
|
||||
|
||||
/** Mirror of backend candidate_forms/plugins.py FORM_READY_STATUSES. */
|
||||
export const FORM_READY_STATUSES = ['INTERVIEW', 'OFFER', 'HIRED', 'APPROVED']
|
||||
|
||||
export function definitions() {
|
||||
return request('/forms/definitions')
|
||||
}
|
||||
|
||||
export function list({ formId, inboxId, manualUploadCandidateId, jobPostId, formType, top, skip } = {}) {
|
||||
return request('/forms/fetch', {
|
||||
params: {
|
||||
form_id: formId,
|
||||
inbox_id: inboxId,
|
||||
manual_upload_candidate_id: manualUploadCandidateId,
|
||||
job_post_id: jobPostId,
|
||||
form_type: formType,
|
||||
top,
|
||||
skip,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function create(body) {
|
||||
return request('/forms/create', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function update(formId, body) {
|
||||
return request('/forms/update', {
|
||||
method: 'PATCH',
|
||||
params: { form_id: formId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function remove(formId) {
|
||||
return request('/forms/delete', {
|
||||
method: 'DELETE',
|
||||
params: { form_id: formId },
|
||||
})
|
||||
}
|
||||
|
|
@ -124,6 +124,14 @@ export function toOfferView(row, { people, jobTitles } = {}) {
|
|||
equity: equityLabel(row.equity_units, row.equity_instrument),
|
||||
equityUnits: row.equity_units ?? null,
|
||||
equityInstrument: row.equity_instrument || null,
|
||||
// Annexure J (offer email format) fields.
|
||||
cadre: row.cadre || null,
|
||||
grossSalaryInWords: row.gross_salary_in_words || null,
|
||||
subsidizedServices: row.subsidized_services || null,
|
||||
probationPeriod: row.probation_period || null,
|
||||
noticePeriod: row.notice_period || null,
|
||||
workLocation: row.work_location || null,
|
||||
workTimings: row.work_timings || null,
|
||||
startDate: row.start_date ? new Date(row.start_date) : null,
|
||||
expiry: row.expiry_date ? new Date(row.expiry_date) : null,
|
||||
sent: row.sent_at ? new Date(row.sent_at) : null,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,11 @@ export const qk = {
|
|||
recruiters: (p = {}) => ['analytics', 'recruiters', p],
|
||||
},
|
||||
offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] },
|
||||
forms: {
|
||||
all: () => ['forms'],
|
||||
list: (p = {}) => ['forms', 'list', p],
|
||||
definitions: () => ['forms', 'definitions'],
|
||||
},
|
||||
interviews: {
|
||||
all: () => ['interviews'],
|
||||
range: (p = {}) => ['interviews', 'range', p],
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,24 @@
|
|||
/* Full-page candidate profile — /candidate/:userId.
|
||||
|
||||
The modal outgrew its box: ten tabs of forms, rating tables and audit trail
|
||||
need a real page with a real URL (shareable, refresh-safe). This is a thin
|
||||
shell over CandidateProfile in `variant="page"` mode: the identity shell
|
||||
carries only the userId and the live detail query fills everything else.
|
||||
Opened from Candidates, Talent Pool and the Pipeline board. */
|
||||
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import CandidateProfile from './CandidateProfile'
|
||||
|
||||
export default function CandidatePage() {
|
||||
const { userId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<CandidateProfile
|
||||
variant="page"
|
||||
candidate={{ id: userId, userId, name: '' }}
|
||||
onClose={() => (window.history.length > 1 ? navigate(-1) : navigate('/candidates'))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -33,9 +33,16 @@ import { seedQuery } from '../data/seedQueries'
|
|||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as formsApi from '../api/forms'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import CandidateFormsTab from './CandidateForms'
|
||||
import { companies, fmtDate, moneyK, pick } from '../data/seed'
|
||||
|
||||
const TABS = ['Overview', 'Resume', 'Timeline', 'History', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
|
||||
/* Workflow order: learn (Overview, Resume, Documents) → interview (Interview,
|
||||
Forms, Feedback) → track (Notes, Activity) → audit (Timeline, History). */
|
||||
const TABS = ['Overview', 'Resume', 'Documents', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History']
|
||||
// Forward progression for the live Advance button. Rejected has no next stage.
|
||||
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', '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']
|
||||
|
|
@ -114,8 +121,10 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) {
|
|||
*/
|
||||
export default function CandidateProfile({
|
||||
candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch,
|
||||
variant = 'modal',
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const { can } = useAuth()
|
||||
const [tab, setTab] = useState('Overview')
|
||||
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
|
|
@ -135,6 +144,17 @@ export default function CandidateProfile({
|
|||
// which is the one the header is describing.
|
||||
const inboxId = live?.inbox_id ?? null
|
||||
|
||||
// Same key as the Forms tab's own query, so the tab count and the tab body
|
||||
// share one fetch. Fetching is not stage-gated (only creating is). Manual
|
||||
// candidates key by their manual_upload_candidate row instead of inbox.
|
||||
const manualFormsId = !inboxId ? (live?.manual_upload_candidate_id ?? null) : null
|
||||
const formsParams = inboxId ? { inboxId } : { manualUploadCandidateId: manualFormsId }
|
||||
const formsQuery = useQuery({
|
||||
queryKey: qk.forms.list(formsParams),
|
||||
queryFn: () => formsApi.list(formsParams),
|
||||
enabled: isLive && Boolean(inboxId || manualFormsId) && can('interviews.view'),
|
||||
})
|
||||
|
||||
const favorite = live ? Boolean(live.favorite) : c.favorite
|
||||
const setFavorite = useProfileWrite({
|
||||
userId: c.userId,
|
||||
|
|
@ -164,8 +184,45 @@ export default function CandidateProfile({
|
|||
const title = live?.job_title || c.currentTitle
|
||||
const company = live?.currentCompany || c.currentCompany
|
||||
|
||||
// Live stage comes from the application record, not from whatever card
|
||||
// opened the modal — c.stage goes stale the moment the stage moves.
|
||||
const rawStatus = String(live?.application_status || '').toUpperCase()
|
||||
const stageLabel = isLive
|
||||
? (pipelineApi.STAGE_FROM_STATUS[rawStatus] ?? 'Shortlist')
|
||||
: c.stage
|
||||
const stageIdx = KANBAN_ORDER.indexOf(stageLabel)
|
||||
const nextStage = stageIdx >= 0 && stageIdx < KANBAN_ORDER.length - 1
|
||||
? KANBAN_ORDER[stageIdx + 1]
|
||||
: null
|
||||
|
||||
// The REAL stage move (PATCH /candidate/stage) — the seed-only onAdvance walk
|
||||
// is kept for seed candidates only. Requires pipeline.edit server-side.
|
||||
const qc = useQueryClient()
|
||||
const advanceLive = useProfileWrite({
|
||||
userId: c.userId,
|
||||
mutationFn: () => pipelineApi.changeStage({
|
||||
inboxId: live?.inbox_id ?? undefined,
|
||||
manualUploadId: live?.inbox_id ? undefined : (live?.manual_upload_candidate_id ?? undefined),
|
||||
toStage: pipelineApi.STATUS_FROM_STAGE[nextStage],
|
||||
changeReason: 'advanced from candidate profile',
|
||||
}),
|
||||
success: () => `Moved to ${nextStage}`,
|
||||
onDone: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.forms.all() })
|
||||
},
|
||||
})
|
||||
|
||||
// The hero experience chip: live experience is free text ("6 years"), seed is
|
||||
// a number. Render nothing rather than a bare "yrs exp".
|
||||
const expRaw = live?.experience ?? c.experience
|
||||
const expChip = expRaw == null || expRaw === ''
|
||||
? null
|
||||
: Number.isFinite(Number(expRaw)) ? `${expRaw} yrs exp` : String(expRaw)
|
||||
|
||||
const counts = live && {
|
||||
Interview: live.interviews?.length ?? 0,
|
||||
Forms: formsQuery.data?.total ?? 0,
|
||||
Notes: live.notes?.length ?? 0,
|
||||
Activity: live.activity?.length ?? 0,
|
||||
Documents: live.documents?.length ?? 0,
|
||||
|
|
@ -186,48 +243,62 @@ export default function CandidateProfile({
|
|||
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Candidate Profile"
|
||||
subtitle={c.id}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
|
||||
style={{ marginRight: 'auto' }}
|
||||
disabled={isLive && (setFavorite.isPending || !live)}
|
||||
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
|
||||
>
|
||||
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
|
||||
</button>
|
||||
{canScoreAts && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={scoreAts.isPending}
|
||||
onClick={() => scoreAts.mutate()}
|
||||
>
|
||||
<Icon name="sparkles" /> {scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
|
||||
<Icon name="target" /> ATS Match
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
|
||||
<Icon name="check" /> Advance Stage
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
const actions = (
|
||||
<>
|
||||
<button
|
||||
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
|
||||
style={{ marginRight: 'auto' }}
|
||||
disabled={isLive && (setFavorite.isPending || !live)}
|
||||
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
|
||||
>
|
||||
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
|
||||
</button>
|
||||
{canScoreAts && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={scoreAts.isPending}
|
||||
onClick={() => scoreAts.mutate()}
|
||||
>
|
||||
<Icon name="sparkles" /> {scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
|
||||
</button>
|
||||
)}
|
||||
{onAtsMatch && (
|
||||
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
|
||||
<Icon name="target" /> ATS Match
|
||||
</button>
|
||||
)}
|
||||
{isLive ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!live || !nextStage || advanceLive.isPending || !can('pipeline.edit')}
|
||||
data-tip={!can('pipeline.edit') ? 'Needs pipeline.edit' : undefined}
|
||||
onClick={() => advanceLive.mutate()}
|
||||
>
|
||||
<Icon name="check" />{' '}
|
||||
{advanceLive.isPending
|
||||
? 'Moving…'
|
||||
: nextStage ? `Advance to ${nextStage}`
|
||||
: stageLabel === 'Rejected' ? 'Rejected' : 'Pipeline complete'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
|
||||
<Icon name="check" /> Advance Stage
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div className="profile-hero">
|
||||
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
|
||||
<Avatar name={live?.name || c.name} initials={c.initials} color={c.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ph-name">{live?.name || c.name}</div>
|
||||
<div className="ph-role">{company ? `${title} at ${company}` : title}</div>
|
||||
<div className="ph-tags">
|
||||
<Badge>{c.stage}</Badge> <Badge className="b-gray">{live?.source || c.source}</Badge>
|
||||
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
|
||||
{stageLabel && <Badge>{stageLabel}</Badge>}{' '}
|
||||
{(live?.source || c.source) && <Badge className="b-gray">{live?.source || c.source}</Badge>}
|
||||
{expChip && <span className="badge b-plain b-indigo badge-plain">{expChip}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* No score anywhere -> the whole block goes, rather than a ring drawn
|
||||
|
|
@ -247,6 +318,7 @@ export default function CandidateProfile({
|
|||
<Tabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
className="tabs tabs-wrap"
|
||||
tabs={TABS.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -273,7 +345,7 @@ export default function CandidateProfile({
|
|||
disabled={setRating.isPending}
|
||||
onChange={(n) => setRating.mutate(n)}
|
||||
/>
|
||||
<span className="cell-sub">{rating.toFixed(1)} / 5.0</span>
|
||||
<span className="cell-sub">{rating ? `${rating.toFixed(1)} / 5.0` : 'Not rated'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Info label="Applications" val={live.job_posts?.length || 0} />
|
||||
|
|
@ -420,6 +492,14 @@ export default function CandidateProfile({
|
|||
)
|
||||
)))}
|
||||
|
||||
{tab === 'Forms' && (guard || (live ? (
|
||||
<CandidateFormsTab userId={c.userId} live={live} />
|
||||
) : (
|
||||
<EmptyState icon="file" title="Live candidates only">
|
||||
Hiring forms attach to real applications.
|
||||
</EmptyState>
|
||||
)))}
|
||||
|
||||
{tab === 'Notes' && (guard || (live ? (
|
||||
<NotesTab userId={c.userId} rows={live.notes ?? []} />
|
||||
) : (
|
||||
|
|
@ -531,6 +611,31 @@ export default function CandidateProfile({
|
|||
</>
|
||||
)))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (variant === 'page') {
|
||||
return (
|
||||
<div className="cand-page">
|
||||
<div className="cand-page-bar">
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>
|
||||
<Icon name="chevron-left" /> Back
|
||||
</button>
|
||||
<div className="cand-page-crumb">
|
||||
Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
|
||||
</div>
|
||||
<div className="cand-page-actions">{actions}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-body">{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Candidate Profile" subtitle={c.id} size="modal-lg" onClose={onClose} footer={actions}>
|
||||
{body}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -847,6 +952,12 @@ function InterviewTab({ userId, inboxId, rows }) {
|
|||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Scheduling…' : 'Schedule Interview'}
|
||||
</button>
|
||||
{!inboxId && (
|
||||
<p className="text-muted" style={{ marginTop: 8, fontSize: 12.5 }}>
|
||||
Interview records attach to an email application — this candidate was added
|
||||
manually, so scheduling is unavailable here.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1038,6 +1149,12 @@ function ActivityTab({ userId, inboxId, rows }) {
|
|||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Logging…' : 'Log Activity'}
|
||||
</button>
|
||||
{!inboxId && (
|
||||
<p className="text-muted" style={{ marginTop: 8, fontSize: 12.5 }}>
|
||||
The activity log attaches to an email application — this candidate was added
|
||||
manually, so logging is unavailable here.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1266,6 +1383,12 @@ function FeedbackTab({ userId, inboxId, rows }) {
|
|||
>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,14 +141,18 @@ export default function Candidates() {
|
|||
|
||||
const openProfile = useCallback(
|
||||
(c) => {
|
||||
setProfileFor(c)
|
||||
qc.setQueryData(qk.seed.recentlyViewed(), (old = []) => {
|
||||
const next = [c.id, ...old.filter((id) => id !== c.id)].slice(0, 12)
|
||||
persist('tf-recent', next)
|
||||
return next
|
||||
})
|
||||
// Real candidates get the full profile PAGE; the modal stays only as the
|
||||
// fallback for rows without a user account.
|
||||
const uid = c.userId || c.id
|
||||
if (uid) navigate(`/candidate/${uid}`)
|
||||
else setProfileFor(c)
|
||||
},
|
||||
[qc],
|
||||
[qc, navigate],
|
||||
)
|
||||
|
||||
// Deep links from Talent Pool, global search, dashboard…
|
||||
|
|
@ -156,11 +160,8 @@ export default function Candidates() {
|
|||
const st = location.state
|
||||
if (!st) return
|
||||
if (st.openAdd) setAdding(true)
|
||||
if (st.openCandidate) {
|
||||
const c = candidates.find((x) => x.id === st.openCandidate)
|
||||
if (c) openProfile(c)
|
||||
}
|
||||
}, [location.state, candidates, openProfile])
|
||||
if (st.openCandidate) navigate(`/candidate/${st.openCandidate}`, { replace: true })
|
||||
}, [location.state, navigate])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const f = filters
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||
import DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard } from '../ui/primitives'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, Stars } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
|
|
|
|||
|
|
@ -394,6 +394,7 @@ export default function Matching() {
|
|||
<Tabs
|
||||
value={tab}
|
||||
onChange={(t) => { setTab(t); setSelectedId(null) }}
|
||||
className="tabs tabs-wrap"
|
||||
tabs={TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
|
|
|
|||
|
|
@ -241,10 +241,10 @@ export default function Pipeline() {
|
|||
onClick={() => {
|
||||
// Don't open the profile on the click that ends a drag.
|
||||
if (draggingId) return
|
||||
// The Candidates screen keys its rows by users.id, so an
|
||||
// application with no linked account cannot deep-link.
|
||||
// The profile page keys off users.id, so an application
|
||||
// with no linked account cannot deep-link.
|
||||
if (!c.userId) return
|
||||
navigate('/candidates', { state: { openCandidate: c.userId } })
|
||||
navigate(`/candidate/${c.userId}`)
|
||||
}}
|
||||
>
|
||||
<div className="k-card-top">
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
|
|
@ -124,6 +125,14 @@ export default function TalentPool() {
|
|||
const [dept, setDept] = useState('')
|
||||
const [profileFor, setProfileFor] = useState(null)
|
||||
const [atsFor, setAtsFor] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Real candidates get the full profile PAGE; the in-place modal remains only
|
||||
// for seed cards that have no user account to deep-link.
|
||||
const openProfile = (c) => {
|
||||
if (c.userId) navigate(`/candidate/${c.userId}`)
|
||||
else setProfileFor(c)
|
||||
}
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.candidates.list({ limit: FETCH_LIMIT }),
|
||||
|
|
@ -238,7 +247,7 @@ export default function TalentPool() {
|
|||
key={c.id}
|
||||
className="card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setProfileFor(c)}
|
||||
onClick={() => openProfile(c)}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
|
||||
|
|
@ -268,7 +277,7 @@ export default function TalentPool() {
|
|||
<AtsMatch
|
||||
candidate={atsFor}
|
||||
onClose={() => setAtsFor(null)}
|
||||
onProfile={(c) => { setAtsFor(null); setProfileFor(c) }}
|
||||
onProfile={(c) => { setAtsFor(null); openProfile(c) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1406,3 +1406,98 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
@media (max-width: 640px) {
|
||||
.g-kpi-7 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Hiring forms — the candidate profile Forms tab (CandidateForms.jsx).
|
||||
Digitized paper annexures: requisition, interview analysis, cultural
|
||||
fit, offer. Namespaced .hf-* ; consumes only global tokens so both
|
||||
themes come for free. */
|
||||
|
||||
/* Score summary: stat tiles, hero = combined overall */
|
||||
.hf-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 18px; }
|
||||
.hf-tile { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; min-width: 0; }
|
||||
.hf-tile .hf-k { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.hf-tile .hf-v { font-size: 20px; font-weight: 700; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
||||
.hf-tile .hf-v small { font-size: 12px; font-weight: 600; color: var(--text-3); margin-left: 2px; }
|
||||
.hf-tile .hf-sub { font-size: 11.5px; color: var(--text-3); margin-top: 4px; }
|
||||
.hf-tile.hero { background: var(--primary-soft); border-color: var(--border-strong); }
|
||||
.hf-meter { height: 4px; border-radius: 2px; background: var(--border); margin-top: 8px; overflow: hidden; }
|
||||
.hf-meter i { display: block; height: 100%; border-radius: 2px; background: var(--primary); }
|
||||
|
||||
/* Bordered section card, titled like the paper form's section headers */
|
||||
.hf-block { border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-top: 14px; }
|
||||
.hf-block-title { font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--text-3); margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
|
||||
.hf-block-title label { display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600; color: var(--text-2); }
|
||||
.hf-note { font-size: 12.5px; color: var(--text-3); margin: 2px 0 10px; }
|
||||
|
||||
/* Rating table: the paper grid — scale header, radio-dot cells, average foot */
|
||||
.hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.hf-rate-head, .hf-rate-row, .hf-rate-foot { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 96px); align-items: center; }
|
||||
.hf-rate-head { background: var(--bg-sunken); }
|
||||
.hf-rate-head > div { padding: 8px 10px; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; color: var(--text-3); text-align: center; line-height: 1.25; }
|
||||
.hf-rate-head > div:first-child { text-align: left; }
|
||||
.hf-rate-row { border-top: 1px solid var(--border); }
|
||||
.hf-rate-row > div:first-child { padding: 9px 10px; font-size: 13px; }
|
||||
.hf-rate-cell { display: flex; justify-content: center; }
|
||||
.hf-dot { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border-strong); background: var(--bg-elev); cursor: pointer; transition: border-color .12s, background .12s, box-shadow .12s; padding: 0; }
|
||||
.hf-dot:hover { border-color: var(--primary); }
|
||||
.hf-dot.on { background: var(--primary); border-color: var(--primary); box-shadow: inset 0 0 0 3.5px var(--bg-elev); }
|
||||
.hf-rate-foot { border-top: 1px solid var(--border); background: var(--bg-sunken); }
|
||||
.hf-rate-foot > div:first-child { padding: 8px 10px; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; color: var(--text-3); }
|
||||
.hf-rate-foot .hf-avg { grid-column: 2 / -1; text-align: center; font-size: 13.5px; font-weight: 700; font-variant-numeric: tabular-nums; padding: 8px 0; }
|
||||
|
||||
/* Completion dot on the form switcher */
|
||||
.hf-done { width: 6px; height: 6px; border-radius: 50%; background: var(--success); display: inline-block; margin-left: 7px; vertical-align: middle; }
|
||||
|
||||
/* Approvals: four signature slots */
|
||||
.hf-sign-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.hf-sign { display: flex; flex-direction: column; gap: 6px; }
|
||||
.hf-sign .hf-sign-role { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; color: var(--text-3); }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
/* Rating columns keep their full 96px width here — the word headers still
|
||||
fit. The shrink + numeric headers happen together at 640px. */
|
||||
.hf-summary { grid-template-columns: repeat(2, 1fr); }
|
||||
.hf-sign-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* Modal tab strips: wrap instead of clipping behind a horizontal scrollbar —
|
||||
ten tabs do not fit the 860px profile modal. */
|
||||
.tabs-wrap { flex-wrap: wrap; overflow-x: visible; }
|
||||
|
||||
/* Full-page candidate profile (/candidate/:userId).
|
||||
The page centers itself with a generous cap so ultrawide monitors don't get
|
||||
a mile-wide form, and everything below the cap is fluid — no fixed widths. */
|
||||
.cand-page { max-width: 1440px; margin: 0 auto; }
|
||||
.cand-page-bar { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.cand-page-crumb { font-size: 13.5px; color: var(--text-3); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cand-page-crumb span { margin: 0 2px; }
|
||||
.cand-page-crumb strong { color: var(--text); font-weight: 600; }
|
||||
.cand-page-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.cand-page-actions .star-btn { margin-right: 0 !important; }
|
||||
/* The four-form switcher must wrap rather than overflow on narrow screens. */
|
||||
.cand-page .seg { flex-wrap: wrap; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cand-page-actions { width: 100%; }
|
||||
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
|
||||
.hf-sign-grid { grid-template-columns: 1fr; }
|
||||
.hf-summary { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.hf-summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Rating-table scale header: full words down to 640px, bare numbers below. */
|
||||
.hf-scale-short { display: none; }
|
||||
@media (max-width: 640px) {
|
||||
.hf-scale-full { display: none; }
|
||||
.hf-scale-short { display: inline; font-size: 12px; }
|
||||
.hf-rate-head, .hf-rate-row, .hf-rate-foot { grid-template-columns: minmax(0, 1fr) repeat(4, 44px); }
|
||||
}
|
||||
|
||||
/* Empty-state CTA on the hiring forms: full-size, and full-width on phones. */
|
||||
.hf-cta { padding: 11px 26px; font-size: 14.5px; }
|
||||
@media (max-width: 640px) {
|
||||
.hf-cta { width: 100%; max-width: 420px; justify-content: center; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue