forms done

Edition_Fomrs
ahmed.mujtaba 2026-09-11 18:37:08 +05:00
parent 1eea85f47e
commit 80a3b290b9
6 changed files with 73 additions and 12 deletions

1
.gitignore vendored
View File

@ -93,3 +93,4 @@ frontend/dist/index.html
frontend/dist/** frontend/dist/**
nginx.conf nginx.conf
smoke.test.mjs smoke.test.mjs
Annex**

View File

@ -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;

View File

@ -5,8 +5,8 @@ import { request } from '../lib/apiClient'
The digitized hiring forms: Annexure A (Employee Requisition), and the two The digitized hiring forms: Annexure A (Employee Requisition), and the two
halves of Annexure E Interview Analysis (technical + behavioral) and halves of Annexure E Interview Analysis (technical + behavioral) and
Cultural Fit. Dual-key like assessments: exactly one of inbox_id / HR Evaluation (form_type stays "cultural_fit"). Dual-key like assessments:
manual_upload_candidate_id. exactly one of inbox_id / manual_upload_candidate_id.
Permissioned with the interviews module tags (interviews.view to read, Permissioned with the interviews module tags (interviews.view to read,
interviews.create to fill, interviews.edit to amend). Creating is interviews.create to fill, interviews.edit to amend). Creating is

View File

@ -1,7 +1,9 @@
/* The Forms tab of the candidate profile modal Interview Analysis + Cultural /* The Forms tab of the candidate profile modal Interview Analysis + HR
Fit (the two halves of Annexure E), and the Offer (Annexure J fields on the Evaluation (the two halves of Annexure E; form_type stays "cultural_fit"
offers table). Employee Requisition (Annexure A) lives on the Requisitions only its title/criteria grew to the fuller HR Evaluation section in
screen, not on a candidate. 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 Field and criterion labels are rendered from GET /forms/definitions the
backend is the single authority for the paper forms' exact wording. 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 = [ const segTabs = [
{ key: 'interview_analysis', label: 'Interview Analysis' }, { key: 'interview_analysis', label: 'Interview Analysis' },
{ key: 'cultural_fit', label: 'Cultural Fit' }, { key: 'cultural_fit', label: 'HR Evaluation' },
...(!isManager ? [{ key: 'offer', label: 'Offer' }] : []), ...(!isManager ? [{ key: 'offer', label: 'Offer' }] : []),
] ]
@ -233,7 +235,7 @@ function SummaryStrip({ summary, evalCount }) {
<div className="hf-summary" style={{ marginBottom: 6 }}> <div className="hf-summary" style={{ marginBottom: 6 }}>
<ScoreTile label="Technical" value={summary.technical_avg} /> <ScoreTile label="Technical" value={summary.technical_avg} />
<ScoreTile label="Behavioral" value={summary.behavioral_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 <ScoreTile
label="Combined Overall" label="Combined Overall"
value={summary.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. */ sections; both types share this component. */
function RatedEvaluationForm({ formType, def, defs, rows, userId, link, live, canCreate, canEdit }) { function RatedEvaluationForm({ formType, def, defs, rows, userId, link, live, canCreate, canEdit }) {

View File

@ -389,7 +389,7 @@ export default function CandidateProfile({
candidate={live || c} stage={stageLabel} nextStage={nextStage} onTab={changeTab} onAction={openAction} candidate={live || c} stage={stageLabel} nextStage={nextStage} onTab={changeTab} onAction={openAction}
rating={rating} ratingPending={setRating.isPending} onRating={(n) => setRating.mutate(n)} rating={rating} ratingPending={setRating.isPending} onRating={(n) => setRating.mutate(n)}
atsScore={atsScore} recommendation={recommendation} 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 ? ( /> : live ? (
<> <>
<PreviousApplications row={live} /> <PreviousApplications row={live} />

View File

@ -275,6 +275,9 @@ function blankForm() {
date_needed: '', date_needed: '',
type: '', type: '',
job_description: '', job_description: '',
period_from: '',
period_to: '',
jd_available: false,
to_replace: '', to_replace: '',
grade: '', grade: '',
recruitment_title: '', recruitment_title: '',
@ -284,6 +287,7 @@ function blankForm() {
recommended_grade: '', recommended_grade: '',
employee_name: '', employee_name: '',
employee_department: '', employee_department: '',
entity: '',
initiated_by: '', initiated_by: '',
initiated_date: '', initiated_date: '',
recommended_by: '', recommended_by: '',
@ -310,6 +314,9 @@ function fromRow(row) {
date_needed: toDateInput(pos.date_needed), date_needed: toDateInput(pos.date_needed),
type: pos.type || '', type: pos.type || '',
job_description: pos.job_description || '', 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 || '', to_replace: rep.to_replace || '',
grade: rep.grade || '', grade: rep.grade || '',
recruitment_title: rep.title || '', recruitment_title: rep.title || '',
@ -319,6 +326,7 @@ function fromRow(row) {
recommended_grade: rep.recommended_grade || '', recommended_grade: rep.recommended_grade || '',
employee_name: ref.employee_name || '', employee_name: ref.employee_name || '',
employee_department: ref.employee_department || '', employee_department: ref.employee_department || '',
entity: ref.entity || '',
initiated_by: row.initiated_by || '', initiated_by: row.initiated_by || '',
initiated_date: toDateInput(row.initiated_date), initiated_date: toDateInput(row.initiated_date),
recommended_by: row.recommended_by || '', recommended_by: row.recommended_by || '',
@ -330,7 +338,7 @@ function fromRow(row) {
approved_by_svp: row.approved_by_svp === true, approved_by_svp: row.approved_by_svp === true,
approved_by_date_svp: toDateInput(row.approved_by_date_svp), 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_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), date_needed: emptyToNull(f.date_needed),
type: emptyToNull(f.type), type: emptyToNull(f.type),
job_description: emptyToNull(f.job_description), 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_by: emptyToNull(f.initiated_by),
initiated_date: emptyToNull(f.initiated_date), initiated_date: emptyToNull(f.initiated_date),
@ -377,10 +388,12 @@ function toPayload(f) {
? { ? {
employee_name: emptyToNull(f.employee_name), employee_name: emptyToNull(f.employee_name),
employee_department: emptyToNull(f.employee_department), employee_department: emptyToNull(f.employee_department),
entity: emptyToNull(f.entity),
} }
: { : {
employee_name: null, employee_name: null,
employee_department: null, employee_department: null,
entity: null,
}, },
} }
return body return body
@ -468,6 +481,22 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
))} ))}
</select> </select>
</div> </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"> <div className="form-field col-span-2">
<label>Job description</label> <label>Job description</label>
<textarea <textarea
@ -477,6 +506,16 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
onChange={(e) => set('job_description', e.target.value)} onChange={(e) => set('job_description', e.target.value)}
/> />
</div> </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>
</div> </div>
@ -564,6 +603,10 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
onChange={(e) => set('employee_department', e.target.value)} onChange={(e) => set('employee_department', e.target.value)}
/> />
</div> </div>
<div className="form-field">
<label>Entity</label>
<input value={fields.entity} onChange={(e) => set('entity', e.target.value)} />
</div>
</div> </div>
)} )}
</div> </div>