Compare commits
2 Commits
1eea85f47e
...
a021d60fc3
| Author | SHA1 | Date |
|---|---|---|
|
|
a021d60fc3 | |
|
|
80a3b290b9 |
|
|
@ -92,4 +92,5 @@ frontend/dist/index.html
|
|||
# nothing under either path that should be ignored.
|
||||
frontend/dist/**
|
||||
nginx.conf
|
||||
smoke.test.mjs
|
||||
smoke.test.mjs
|
||||
Annex**
|
||||
|
|
@ -16,10 +16,14 @@ class Position(BaseModel):
|
|||
date_needed:Optional[date]
|
||||
type:Optional[EmploymentType]
|
||||
job_description:Optional[str]
|
||||
period_from:Optional[date]=None
|
||||
period_to:Optional[date]=None
|
||||
jd_available:Optional[bool]=None
|
||||
|
||||
class InternalRecommendate(BaseModel):
|
||||
employee_name:Optional[str]=None
|
||||
employee_department:Optional[str]=None
|
||||
entity:Optional[str]=None
|
||||
|
||||
class ReplacementFor(BaseModel):
|
||||
to_replace:Optional[str]
|
||||
|
|
|
|||
|
|
@ -34,9 +34,13 @@ class Requisition(SQLModel, table=True):
|
|||
),
|
||||
)
|
||||
job_description: Optional[str] = None
|
||||
period_from: Optional[Date] = None
|
||||
period_to: Optional[Date] = None
|
||||
jd_available: Optional[bool] = None
|
||||
|
||||
employee_name: Optional[str] = None
|
||||
employee_department: Optional[str] = None
|
||||
entity: Optional[str] = None
|
||||
|
||||
to_replace: Optional[str] = None
|
||||
grade: Optional[str] = None
|
||||
|
|
@ -139,8 +143,12 @@ class Requisition(SQLModel, table=True):
|
|||
date_needed=position.get("date_needed") if position.get("date_needed") else None,
|
||||
employment_type=EmploymentType(position.get("type")) if position.get("type") else None,
|
||||
job_description=position.get("job_description") if position.get("job_description") else None,
|
||||
period_from=position.get("period_from") if position.get("period_from") else None,
|
||||
period_to=position.get("period_to") if position.get("period_to") else None,
|
||||
jd_available=position.get("jd_available") if position.get("jd_available") is not None else None,
|
||||
employee_name=referral.get("employee_name") if referral.get("employee_name") else None,
|
||||
employee_department=referral.get("employee_department") if referral.get("employee_department") else None,
|
||||
entity=referral.get("entity") if referral.get("entity") else None,
|
||||
to_replace=replacement.get("to_replace") if replacement.get("to_replace") else None,
|
||||
grade=replacement.get("grade") if replacement.get("grade") else None,
|
||||
recruitment_title=replacement.get("title") if replacement.get("title") else None,
|
||||
|
|
@ -183,6 +191,12 @@ class Requisition(SQLModel, table=True):
|
|||
row.employment_type = EmploymentType(position.get("type")) if position.get("type") else None
|
||||
if "job_description" in position:
|
||||
row.job_description = position.get("job_description") if position.get("job_description") else None
|
||||
if "period_from" in position:
|
||||
row.period_from = position.get("period_from") if position.get("period_from") else None
|
||||
if "period_to" in position:
|
||||
row.period_to = position.get("period_to") if position.get("period_to") else None
|
||||
if "jd_available" in position:
|
||||
row.jd_available = position.get("jd_available") if position.get("jd_available") is not None else None
|
||||
if "replacement_for" in fields:
|
||||
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
|
||||
if "to_replace" in replacement:
|
||||
|
|
@ -205,6 +219,8 @@ class Requisition(SQLModel, table=True):
|
|||
row.employee_name = referral.get("employee_name") if referral.get("employee_name") else None
|
||||
if "employee_department" in referral:
|
||||
row.employee_department = referral.get("employee_department") if referral.get("employee_department") else None
|
||||
if "entity" in referral:
|
||||
row.entity = referral.get("entity") if referral.get("entity") else None
|
||||
if "initiated_by" in fields:
|
||||
row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None
|
||||
if "initiated_date" in fields:
|
||||
|
|
|
|||
|
|
@ -80,7 +80,8 @@ FORM_DEFINITIONS = {
|
|||
"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": "problem_solving", "label": "Problem Solving"},
|
||||
{"key": "analytical_reasoning", "label": "Analytical Reasoning"},
|
||||
{"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"},
|
||||
{"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"},
|
||||
],
|
||||
|
|
@ -109,27 +110,41 @@ FORM_DEFINITIONS = {
|
|||
),
|
||||
"has_recommendation": True,
|
||||
},
|
||||
# form_type/section/field keys below stay "cultural_fit"/"cultural"/"cultural_note" —
|
||||
# renamed labels only. Titles and criterion labels are denormalized into every
|
||||
# saved row at write time (see module docstring), so historical rows keep the
|
||||
# "Cultural Fit" wording they were saved under while new rows pick up the fuller
|
||||
# revision 2 "HR Evaluation" section below; the key stays stable so old rows keep
|
||||
# validating and combined_summary()'s "cultural" lookup keeps matching both.
|
||||
"cultural_fit": {
|
||||
"title": "Cultural Fit",
|
||||
"title": "HR Evaluation",
|
||||
"source": "Annexure E - Interview Evaluation Form",
|
||||
"scale_note": RATING_SCALE_NOTE,
|
||||
"sections": [
|
||||
{
|
||||
"key": "cultural",
|
||||
"title": "CULTURAL FIT",
|
||||
"average_label": "CULTURAL FIT SECTION",
|
||||
"title": "HR EVALUATION",
|
||||
"average_label": "HR EVALUATION SECTION",
|
||||
"criteria": [
|
||||
{"key": "company_values", "label": "Alignment with Company Values"},
|
||||
{"key": "basic_jd_requirement", "label": "Basic JD requirement"},
|
||||
{"key": "company_values", "label": "Alignment with Company Culture"},
|
||||
{"key": "professionalism", "label": "Professionalism & Integrity"},
|
||||
{"key": "collaboration", "label": "Collaboration & Team Orientation"},
|
||||
{"key": "adaptability", "label": "Adaptability to Change"},
|
||||
{"key": "work_ethic", "label": "Work Ethic & Reliability"},
|
||||
{"key": "adaptability", "label": "Adaptability"},
|
||||
{"key": "agility", "label": "Agility"},
|
||||
{"key": "work_ethic", "label": "Work Ethics"},
|
||||
{"key": "communication_articulation", "label": "Communication & Articulation"},
|
||||
{"key": "problem_solving_orientation", "label": "Problem Solving & Solution Orientation"},
|
||||
{"key": "critical_thinking", "label": "Critical Thinking & Analytical Capability"},
|
||||
{"key": "initiative", "label": "Initiative & Proactiveness"},
|
||||
{"key": "decision_making", "label": "Decision Making"},
|
||||
{"key": "leadership", "label": "Leadership"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"fields": (
|
||||
_EVALUATION_HEADER_FIELDS
|
||||
+ [{"key": "cultural_note", "label": "Cultural Fit — Notes", "kind": "text"}]
|
||||
+ [{"key": "cultural_note", "label": "HR Evaluation — Notes", "kind": "text"}]
|
||||
+ _EVALUATION_FOOTER_FIELDS
|
||||
),
|
||||
"has_recommendation": True,
|
||||
|
|
@ -174,6 +189,7 @@ FORM_DEFINITIONS = {
|
|||
{"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": "entity", "label": "Entity", "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"},
|
||||
|
|
@ -354,7 +370,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.
|
||||
behavioral averages, the latest cultural_fit row the "cultural" section
|
||||
average — cultural_fit's own section carries the fuller HR Evaluation
|
||||
criteria as of revision 2, but the key stays "cultural" so this lookup
|
||||
(and the `cultural_avg` key below) don't need to change with it.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ def serialize_requisition(row) -> dict:
|
|||
"date_needed": _date(row.date_needed),
|
||||
"type": _enum(row.employment_type),
|
||||
"job_description": row.job_description,
|
||||
"period_from": _date(row.period_from),
|
||||
"period_to": _date(row.period_to),
|
||||
"jd_available": row.jd_available,
|
||||
},
|
||||
"replacement_for": {
|
||||
"to_replace": row.to_replace,
|
||||
|
|
@ -98,6 +101,7 @@ def serialize_requisition(row) -> dict:
|
|||
"refferal_by": {
|
||||
"employee_name": row.employee_name,
|
||||
"employee_department": row.employee_department,
|
||||
"entity": row.entity,
|
||||
},
|
||||
"initiated_by": row.initiated_by,
|
||||
"initiated_date": _date(row.initiated_date),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
-- 037_requisition_period_jd_entity.sql
|
||||
-- Annexure A revision 2 added three fields the original 020 table never had:
|
||||
-- "If not permanent, specify the period From/To" on the position block, the
|
||||
-- mandatory "JD Available Yes/No" flag (distinct from the free-text
|
||||
-- job_description), and "Entity" alongside Employee Name/Department in the
|
||||
-- internal-recommendation block. Matches Requisition in
|
||||
-- backend/candidate_forms/models.py. Applied at startup by
|
||||
-- alembic_setup.run_manual_sql() — needed because prod boots with
|
||||
-- DB_AUTOGENERATE=false and never autogenerates new columns.
|
||||
|
||||
ALTER TABLE app.requisitions
|
||||
ADD COLUMN IF NOT EXISTS period_from date,
|
||||
ADD COLUMN IF NOT EXISTS period_to date,
|
||||
ADD COLUMN IF NOT EXISTS jd_available boolean,
|
||||
ADD COLUMN IF NOT EXISTS entity varchar;
|
||||
|
|
@ -52,7 +52,7 @@ class TestNormalizeSections:
|
|||
normalized, overall = normalize_sections("interview_analysis", [])
|
||||
assert [s["key"] for s in normalized] == ["technical", "behavioral"]
|
||||
technical = normalized[0]
|
||||
assert len(technical["criteria"]) == 5
|
||||
assert len(technical["criteria"]) == 6
|
||||
assert technical["criteria"][0]["label"] == "Core Job Knowledge & Domain Expertise"
|
||||
assert technical["average"] is None
|
||||
assert overall is None
|
||||
|
|
@ -149,8 +149,8 @@ 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]
|
||||
assert [len(s["criteria"]) for s in ia["sections"]] == [6, 5]
|
||||
assert [len(s["criteria"]) for s in cf["sections"]] == [13]
|
||||
|
||||
def test_stage_gate_vocabulary(self):
|
||||
assert set(FORM_READY_STATUSES) == {"INTERVIEW", "OFFER", "HIRED", "APPROVED"}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { request } from '../lib/apiClient'
|
|||
|
||||
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.
|
||||
HR Evaluation (form_type stays "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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/* The Forms tab of the candidate profile modal — Interview Analysis + Cultural
|
||||
Fit (the two halves of Annexure E), and the Offer (Annexure J fields on the
|
||||
offers table). Employee Requisition (Annexure A) lives on the Requisitions
|
||||
screen, not on a candidate.
|
||||
/* The Forms tab of the candidate profile modal — Interview Analysis + HR
|
||||
Evaluation (the two halves of Annexure E; form_type stays "cultural_fit" —
|
||||
only its title/criteria grew to the fuller HR Evaluation section in
|
||||
revision 2), and the Offer (Annexure J fields on the offers table).
|
||||
Employee Requisition (Annexure A) lives on the Requisitions screen, not on
|
||||
a candidate.
|
||||
|
||||
Field and criterion labels are rendered from GET /forms/definitions — the
|
||||
backend is the single authority for the paper forms' exact wording. The
|
||||
|
|
@ -161,7 +163,7 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
}
|
||||
const segTabs = [
|
||||
{ key: 'interview_analysis', label: 'Interview Analysis' },
|
||||
{ key: 'cultural_fit', label: 'Cultural Fit' },
|
||||
{ key: 'cultural_fit', label: 'HR Evaluation' },
|
||||
...(!isManager ? [{ key: 'offer', label: 'Offer' }] : []),
|
||||
]
|
||||
|
||||
|
|
@ -233,7 +235,7 @@ function SummaryStrip({ summary, evalCount }) {
|
|||
<div className="hf-summary" style={{ marginBottom: 6 }}>
|
||||
<ScoreTile label="Technical" value={summary.technical_avg} />
|
||||
<ScoreTile label="Behavioral" value={summary.behavioral_avg} />
|
||||
<ScoreTile label="Cultural Fit" value={summary.cultural_avg} />
|
||||
<ScoreTile label="HR Evaluation" value={summary.cultural_avg} />
|
||||
<ScoreTile
|
||||
label="Combined Overall"
|
||||
value={summary.combined_overall}
|
||||
|
|
@ -335,7 +337,7 @@ function RatingTable({ section, defs, ratings, onRate }) {
|
|||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Interview Analysis / Cultural Fit — data-driven off the definition's
|
||||
Interview Analysis / HR Evaluation — data-driven off the definition's
|
||||
sections; both types share this component. */
|
||||
|
||||
function RatedEvaluationForm({ formType, def, defs, rows, userId, link, live, canCreate, canEdit }) {
|
||||
|
|
|
|||
|
|
@ -389,7 +389,7 @@ export default function CandidateProfile({
|
|||
candidate={live || c} stage={stageLabel} nextStage={nextStage} onTab={changeTab} onAction={openAction}
|
||||
rating={rating} ratingPending={setRating.isPending} onRating={(n) => setRating.mutate(n)}
|
||||
atsScore={atsScore} recommendation={recommendation}
|
||||
atsAction={canScoreAts && can('candidates.create') ? <button className="btn btn-secondary btn-sm" disabled={scoreAts.isPending} onClick={() => scoreAts.mutate()}><Icon name="sparkles" />{scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}</button> : null}
|
||||
atsAction={canRerunAts && can('candidates.create') ? <button className="btn btn-secondary btn-sm" disabled={rerunAts.isPending} onClick={() => rerunAts.mutate()}><Icon name="sparkles" />{rerunAts.isPending ? 'Scoring…' : 'Score with ATS'}</button> : null}
|
||||
/> : live ? (
|
||||
<>
|
||||
<PreviousApplications row={live} />
|
||||
|
|
|
|||
|
|
@ -275,6 +275,9 @@ function blankForm() {
|
|||
date_needed: '',
|
||||
type: '',
|
||||
job_description: '',
|
||||
period_from: '',
|
||||
period_to: '',
|
||||
jd_available: false,
|
||||
to_replace: '',
|
||||
grade: '',
|
||||
recruitment_title: '',
|
||||
|
|
@ -284,6 +287,7 @@ function blankForm() {
|
|||
recommended_grade: '',
|
||||
employee_name: '',
|
||||
employee_department: '',
|
||||
entity: '',
|
||||
initiated_by: '',
|
||||
initiated_date: '',
|
||||
recommended_by: '',
|
||||
|
|
@ -310,6 +314,9 @@ function fromRow(row) {
|
|||
date_needed: toDateInput(pos.date_needed),
|
||||
type: pos.type || '',
|
||||
job_description: pos.job_description || '',
|
||||
period_from: toDateInput(pos.period_from),
|
||||
period_to: toDateInput(pos.period_to),
|
||||
jd_available: pos.jd_available === true,
|
||||
to_replace: rep.to_replace || '',
|
||||
grade: rep.grade || '',
|
||||
recruitment_title: rep.title || '',
|
||||
|
|
@ -319,6 +326,7 @@ function fromRow(row) {
|
|||
recommended_grade: rep.recommended_grade || '',
|
||||
employee_name: ref.employee_name || '',
|
||||
employee_department: ref.employee_department || '',
|
||||
entity: ref.entity || '',
|
||||
initiated_by: row.initiated_by || '',
|
||||
initiated_date: toDateInput(row.initiated_date),
|
||||
recommended_by: row.recommended_by || '',
|
||||
|
|
@ -330,7 +338,7 @@ function fromRow(row) {
|
|||
approved_by_svp: row.approved_by_svp === true,
|
||||
approved_by_date_svp: toDateInput(row.approved_by_date_svp),
|
||||
is_replacement: hasAny(rep, ['to_replace', 'grade', 'title', 'date_separated', 'justification', 'budget', 'recommended_grade']),
|
||||
is_referral: hasAny(ref, ['employee_name', 'employee_department']),
|
||||
is_referral: hasAny(ref, ['employee_name', 'employee_department', 'entity']),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -343,6 +351,9 @@ function toPayload(f) {
|
|||
date_needed: emptyToNull(f.date_needed),
|
||||
type: emptyToNull(f.type),
|
||||
job_description: emptyToNull(f.job_description),
|
||||
period_from: emptyToNull(f.period_from),
|
||||
period_to: emptyToNull(f.period_to),
|
||||
jd_available: f.jd_available,
|
||||
},
|
||||
initiated_by: emptyToNull(f.initiated_by),
|
||||
initiated_date: emptyToNull(f.initiated_date),
|
||||
|
|
@ -377,10 +388,12 @@ function toPayload(f) {
|
|||
? {
|
||||
employee_name: emptyToNull(f.employee_name),
|
||||
employee_department: emptyToNull(f.employee_department),
|
||||
entity: emptyToNull(f.entity),
|
||||
}
|
||||
: {
|
||||
employee_name: null,
|
||||
employee_department: null,
|
||||
entity: null,
|
||||
},
|
||||
}
|
||||
return body
|
||||
|
|
@ -468,6 +481,22 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>If not permanent, period from</label>
|
||||
<input
|
||||
type="date"
|
||||
value={fields.period_from}
|
||||
onChange={(e) => set('period_from', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Period to</label>
|
||||
<input
|
||||
type="date"
|
||||
value={fields.period_to}
|
||||
onChange={(e) => set('period_to', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Job description</label>
|
||||
<textarea
|
||||
|
|
@ -477,6 +506,16 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
onChange={(e) => set('job_description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.jd_available}
|
||||
onChange={(e) => set('jd_available', e.target.checked)}
|
||||
/>
|
||||
{' '}JD available <span className="hf-note" style={{ margin: 0 }}>(mandatory — sourcing will not start without it)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -564,6 +603,10 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
onChange={(e) => set('employee_department', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Entity</label>
|
||||
<input value={fields.entity} onChange={(e) => set('entity', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue