Second slice of Phase 3 (stacked on 3a candidates). The gated 7-stage recruitment pipeline per Crewing-Implementation-Spec §5.1/§8.4–8.5/§8.13. Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged. What's in - Schema (crewing_pipeline migration): Application (one per requisition+candidate) + 7-stage ApplicationStage; ApplicationGate (SALARY/SELECTION/WAIVER pending = Manager queue items); ReferenceCheck; effective-dated SalaryStructure (attached to the Application now, bound to the assignment in 3c); minimal BankDetail/EpfDetail captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). CrewAction += applicationId; pipeline CrewActionTypes. - State machine: lib/application-pipeline.ts — sourcing advances MPO/Manager; approve_salary + select are Manager-only; orthogonal canReject; BOARD_STAGES. - Actions: addApplication (first candidate → requisition SHORTLISTING), advanceStage, recordReferenceCheck, verifyDocuments (bank/EPF), agreeSalary→approveSalary/returnSalary, recordInterviewResult, requestInterviewWaiver→approve/decline, selectCandidate (→ requisition SELECTED)/returnSelection, rejectApplication. Waiver never automatic (R2). Notifications SALARY/SELECTION/WAIVER + CANDIDATE_PROPOSED. - Screens: pipeline board per requisition (7 columns + Add candidate); application workhorse (7-step stepper + adaptive per-stage action card); "Open pipeline" on the requisition detail. Central /approvals gains a crewing section (inline Approve/Return) for one unified Manager queue (§8.13 R8). Tests & docs - Unit: application-pipeline.test.ts (9). Integration: applications.test.ts (10) — full happy path, salary/selection/waiver approvals + Manager-only gating, failed interview, reject, site-staff lockout. type-check clean; full unit (234) + integration (163) green. - CLAUDE.md "Crewing" updated with the Phase 3b surface. Deferred: onboarding (Epic D, Phase 3c) — SELECTED → ONBOARDED, CrewAssignment, employeeId, requisition → FILLED, salary bound to the assignment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
4.2 KiB
TypeScript
99 lines
4.2 KiB
TypeScript
import type { ApplicationStage, Role } from "@prisma/client";
|
|
|
|
// Recruitment pipeline state machine (Crewing-Implementation-Spec §5.1) — mirrors
|
|
// po-state-machine / requisition-state-machine. The 7 board stages advance in
|
|
// order; ONBOARDED is the terminal system state set at onboarding (Phase 3c);
|
|
// REJECTED is an orthogonal branch reachable from any active stage.
|
|
//
|
|
// Stage advances are modelled here. The within-stage work — recording reference
|
|
// checks, capturing bank/EPF, agreeing the salary, recording the interview
|
|
// result, requesting a waiver — happens in server actions; this machine governs
|
|
// when a candidate may move to the next column and who may move them.
|
|
//
|
|
// Manager-gated advances (spec §6): SALARY_AGREEMENT → PROPOSED (salary approval)
|
|
// and INTERVIEW → SELECTED (final selection) are Manager-only. The interview
|
|
// waiver is a separate Manager-approved action (R2), never automatic.
|
|
|
|
export type ApplicationAction =
|
|
| "start_competency" // SHORTLISTED → COMPETENCY_AND_REFERENCES
|
|
| "verify_competency" // COMPETENCY_AND_REFERENCES → DOC_VERIFICATION
|
|
| "verify_docs" // DOC_VERIFICATION → SALARY_AGREEMENT
|
|
| "approve_salary" // SALARY_AGREEMENT → PROPOSED (Manager)
|
|
| "propose_accepted" // PROPOSED → INTERVIEW
|
|
| "select" // INTERVIEW → SELECTED (Manager)
|
|
| "onboard"; // SELECTED → ONBOARDED (Phase 3c)
|
|
|
|
interface Transition {
|
|
to: ApplicationStage;
|
|
allowedRoles: Role[];
|
|
}
|
|
|
|
type TransitionMap = Partial<Record<ApplicationAction, Transition>>;
|
|
|
|
const SOURCING_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
|
|
const MANAGER_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
|
|
|
|
const TRANSITIONS: Partial<Record<ApplicationStage, TransitionMap>> = {
|
|
SHORTLISTED: {
|
|
start_competency: { to: "COMPETENCY_AND_REFERENCES", allowedRoles: SOURCING_ROLES },
|
|
},
|
|
COMPETENCY_AND_REFERENCES: {
|
|
verify_competency: { to: "DOC_VERIFICATION", allowedRoles: SOURCING_ROLES },
|
|
},
|
|
DOC_VERIFICATION: {
|
|
verify_docs: { to: "SALARY_AGREEMENT", allowedRoles: SOURCING_ROLES },
|
|
},
|
|
SALARY_AGREEMENT: {
|
|
// Manager approves the agreed salary structure (spec §6).
|
|
approve_salary: { to: "PROPOSED", allowedRoles: MANAGER_ROLES },
|
|
},
|
|
PROPOSED: {
|
|
propose_accepted: { to: "INTERVIEW", allowedRoles: SOURCING_ROLES },
|
|
},
|
|
INTERVIEW: {
|
|
// Final selection is a Manager approval (spec §6). The action enforces that
|
|
// the interview was accepted or a Manager-approved waiver is in place (R2).
|
|
select: { to: "SELECTED", allowedRoles: MANAGER_ROLES },
|
|
},
|
|
SELECTED: {
|
|
// The onboarding side-effect (Phase 3c) moves SELECTED → ONBOARDED.
|
|
onboard: { to: "ONBOARDED", allowedRoles: SOURCING_ROLES },
|
|
},
|
|
};
|
|
|
|
// The 7 visible board columns, in order (spec §8.4). ONBOARDED/REJECTED are not
|
|
// board columns — they are terminal/branch states.
|
|
export const BOARD_STAGES: ApplicationStage[] = [
|
|
"SHORTLISTED",
|
|
"COMPETENCY_AND_REFERENCES",
|
|
"DOC_VERIFICATION",
|
|
"SALARY_AGREEMENT",
|
|
"PROPOSED",
|
|
"INTERVIEW",
|
|
"SELECTED",
|
|
];
|
|
|
|
export function getTransition(from: ApplicationStage, action: ApplicationAction): Transition | null {
|
|
return TRANSITIONS[from]?.[action] ?? null;
|
|
}
|
|
|
|
export function canPerformAction(from: ApplicationStage, action: ApplicationAction, role: Role): boolean {
|
|
return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
|
|
}
|
|
|
|
export function getAvailableActions(stage: ApplicationStage, role: Role): ApplicationAction[] {
|
|
const map = TRANSITIONS[stage];
|
|
if (!map) return [];
|
|
return (Object.keys(map) as ApplicationAction[]).filter((a) => canPerformAction(stage, a, role));
|
|
}
|
|
|
|
// ── Rejection (orthogonal) ───────────────────────────────────────────────────
|
|
// A candidate may be rejected with remarks from any active stage (not once
|
|
// SELECTED/ONBOARDED, and not again if already REJECTED), by MPO or Manager.
|
|
|
|
export const REJECT_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
|
|
const TERMINAL: ApplicationStage[] = ["SELECTED", "ONBOARDED", "REJECTED"];
|
|
|
|
export function canReject(from: ApplicationStage, role: Role): boolean {
|
|
return !TERMINAL.includes(from) && REJECT_ROLES.includes(role);
|
|
}
|