diff --git a/.gitignore b/.gitignore index 58c95ff..34593ac 100644 --- a/.gitignore +++ b/.gitignore @@ -92,4 +92,5 @@ frontend/dist/index.html # nothing under either path that should be ignored. frontend/dist/** nginx.conf -smoke.test.mjs \ No newline at end of file +smoke.test.mjs +Annex** \ No newline at end of file diff --git a/Main.dc.html b/Main.dc.html new file mode 100644 index 0000000..7fd3571 --- /dev/null +++ b/Main.dc.html @@ -0,0 +1,654 @@ + + + + + + + + + + + + + + +
+ +
+ + + Utopia BrandsHR Portal + + + +
+ +
+ +
+
+ +
+
+ +
+ +
Candidates / Ada Lovelace
+
+ +
+
+ + +
+ AL +
+
+
+
+

Ada Lovelace

+ {{stage}} +
+ +
+
+ + +
+
+
+
Applied forSenior Backend Engineer
+
Applied onMar 12, 2026
+
SourceCareers page
+
Current companyMeridian Systems
+
Experience6 years
+
EducationMSc Computer Science
+
Total applications{{appCount}}
+
+
+
+ + +
+
+ + + +
+
+ + + +
+ +
+
+

Candidate Information

+
+
Full name
Ada Lovelace
+
Email
ada.lovelace@example.com
+
Phone
+1 (555) 214-7788
+
Location
Austin, TX
+
Current company
Meridian Systems
+
Current title
Senior Backend Engineer
+
Experience
6 years
+
Education
MSc Computer Science — Imperial College London
+ +
Notice period
{{noticePeriod}}
+
Expected salary
{{expectedSalary}}
+
+
+
+

Skills & Tags

+
+ PythonFastAPIPostgreSQLDockerKubernetesREST APIsKafkaAWS +
+
+
+ +
+
+
+

Applications ({{appCount}})

+ + + +
+
+ + + + + + + + + + + + +
Job titleApplied onStatusActions
{{a.title}}{{a.sub}}{{a.when}}{{a.status}}
+
+
+ +
+

Professional Summary

+

Senior backend engineer with 6 years building high-throughput payment and fulfillment services. Led the migration of a monolith to event-driven microservices on Kafka, cutting checkout latency by 40%. Comfortable owning a service from design through on-call.

+
+ +
+

Resume

+
+ PDF +
Ada_Lovelace_Resume.pdfPDF · Mar 12, 2026
+
+ + +
+
+
+ +
+

Ratings

+
+
+ + + +
+ {{ratingText}} +
+
+ +
+

Recruiter

+
+ MK +
Meera KhanHiring team
+
+
+ +
+

AI Screening

+
+
+ 82 + Strong Match +
+

Meets every mandatory requirement with demonstrated production experience; missing only the Kubernetes depth the role prefers.

+
+
+ +
+

Suggested Roles

+
Platform EngineerStaff Backend Engineer
+
+
+ + +
+
+ + + +
+
+ PDF +
Ada_Lovelace_Resume.pdfOriginal CV from the application
+
+
+
+
+ + +
+
+ +
Technical — System Design
Mar 15, 2026 · 3:00 PM
+ Scheduled +
+

Scheduling a new round attaches it to this application.

+ +
+
+ + +
+
+ +
Technical scorecard
Submitted by Farhan Ali · Mar 15, 2026
+ Submitted +
+
+ +
Offer approval
Pending hiring manager sign-off
+ Pending +
+
+
+ + +
+
+ + +
+
+ +
+ {{n.initials}} +
{{n.author}}
{{n.text}}
{{n.when}}
+
+
+
+
+
+ + +
+
Profile viewed by Meera Khan
1h ago
+
Email sent: Interview invitation
1 day ago
+
Assessment score updated to 82%
2 days ago
+
+
+ + +
+
    +
  1. Application received

    Applied via Careers page

  2. +
  3. AI screening completed

    Match score: 82%

  4. +
  5. Interview scheduled

    Technical — System Design

  6. +
+
+
+ + +
+ +
Stage changed
Screening → Interview
Meera Khan · 10:14 AM
+
Feedback submitted
by Farhan Ali
+
+
+ +
+
+
+
+ + + diff --git a/backend/candidate_forms/enums.py b/backend/candidate_forms/enums.py index 8cede5b..51b0afa 100644 --- a/backend/candidate_forms/enums.py +++ b/backend/candidate_forms/enums.py @@ -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] diff --git a/backend/candidate_forms/models.py b/backend/candidate_forms/models.py index 5deb238..46e9e2b 100644 --- a/backend/candidate_forms/models.py +++ b/backend/candidate_forms/models.py @@ -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: diff --git a/backend/candidate_forms/plugins.py b/backend/candidate_forms/plugins.py index 3d0addc..a59fd29 100644 --- a/backend/candidate_forms/plugins.py +++ b/backend/candidate_forms/plugins.py @@ -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 diff --git a/backend/candidate_forms/serializers.py b/backend/candidate_forms/serializers.py index b621ed3..b2968de 100644 --- a/backend/candidate_forms/serializers.py +++ b/backend/candidate_forms/serializers.py @@ -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), diff --git a/backend/migrations/manual/037_requisition_period_jd_entity.sql b/backend/migrations/manual/037_requisition_period_jd_entity.sql new file mode 100644 index 0000000..327e473 --- /dev/null +++ b/backend/migrations/manual/037_requisition_period_jd_entity.sql @@ -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; diff --git a/backend/tests/test_candidate_forms.py b/backend/tests/test_candidate_forms.py index 49e1a7a..3cfa57a 100644 --- a/backend/tests/test_candidate_forms.py +++ b/backend/tests/test_candidate_forms.py @@ -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"} diff --git a/frontend/src/api/forms.js b/frontend/src/api/forms.js index e5d310f..8168951 100644 --- a/frontend/src/api/forms.js +++ b/frontend/src/api/forms.js @@ -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 diff --git a/frontend/src/screens/CandidateForms.jsx b/frontend/src/screens/CandidateForms.jsx index 740b13e..8384db4 100644 --- a/frontend/src/screens/CandidateForms.jsx +++ b/frontend/src/screens/CandidateForms.jsx @@ -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 }) {
- + setRating.mutate(n)} atsScore={atsScore} recommendation={recommendation} - atsAction={canScoreAts && can('candidates.create') ? : null} + atsAction={canRerunAts && can('candidates.create') ? : null} /> : live ? ( <> diff --git a/frontend/src/screens/Requisitions.jsx b/frontend/src/screens/Requisitions.jsx index 4300761..16c7a6a 100644 --- a/frontend/src/screens/Requisitions.jsx +++ b/frontend/src/screens/Requisitions.jsx @@ -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 }) { ))}
+
+ + set('period_from', e.target.value)} + /> +
+
+ + set('period_to', e.target.value)} + /> +