Files
emaui/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts
mihretu 24fb51de23 fix(exam): reflect authoritative exam state and lock engine-graded results
- COC queue status cell shows the server-derived exam state (registered,
  present, sat, passed, failed) instead of the lagging application status.
- Portal examStageFor prefers the server's examState so portal and back
  office never disagree; NOT_SITTING stage added.
- Exam roster shows each candidate's result and lock; Regrade hidden once
  a result exists.
- Record Result modal: only unmarked candidates, empty score boxes (no
  silent zeros), reason required, backend refusals translated.
- Result page: auto-graded marks read-only, per-applicant publish.
- Exam page: session window fields, Add Question menu (bank / Excel /
  scratch), wait metrics panel; PUBLISHED exam status removed.
- Portal: exam window shown, early launch and eligibility refusals explained.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 08:12:12 +00:00

75 lines
2.5 KiB
TypeScript

/**
* Which step owns which required fields.
*
* Extracted from the form so the rule "Next checks only the step in front of
* the user" is testable without rendering a modal — and so the final submit
* and the per-step check can never drift apart, since both read this.
*/
export interface ExamFormValues {
certificationId: string | null;
titleEn: string;
titleAm: string;
directionEn: string;
directionAm: string;
date: string;
/** `HH:MM` or empty — the session window, optional on both ends. */
startTime: string;
endTime: string;
venue: string;
type: string | null;
form: string | null;
adminMethod: string | null;
evalMethod: string | null;
cuttingPoint: number;
}
/** Basic Info, Settings, Review — only the last one submits. */
export const LAST_STEP = 2;
export function validateBasic(v: ExamFormValues): string | null {
if (!v.certificationId || !v.titleEn || !v.titleAm || !v.date || !v.venue) {
return 'exam.form.fillRequiredBasic';
}
// Either both languages or neither: a direction in one language only reads
// as missing to half the candidates.
if ((v.directionEn || v.directionAm) && !(v.directionEn && v.directionAm)) {
return 'exam.form.directionBothLanguages';
}
// A session cannot close before it opens. Same rule the API applies
// (exam_window_invalid), caught here so it is reported on the step that
// owns the fields.
if (v.startTime && v.endTime && v.endTime <= v.startTime) {
return 'exam.form.windowInvalid';
}
return null;
}
export function validateSettings(v: ExamFormValues): string | null {
return !v.type || !v.form || !v.adminMethod || !v.evalMethod || !v.cuttingPoint
? 'exam.form.fillRequiredSettings'
: null;
}
/** The step in front of the user, and nothing else. Review has no fields. */
export function validateStep(step: number, v: ExamFormValues): string | null {
if (step === 0) return validateBasic(v);
if (step === 1) return validateSettings(v);
return null;
}
/**
* The whole payload, as the final check before submitting.
*
* Not redundant with the per-step checks: the stepper header can be clicked
* back to a visited step, so arriving at Review is no proof the user walked
* here in order with everything still filled in.
*/
export function validateAll(v: ExamFormValues): string | null {
return validateBasic(v) ?? validateSettings(v);
}
/** Where a failure has to land for the user to be able to fix it. */
export function stepOfError(error: string): number {
return error === 'exam.form.fillRequiredSettings' ? 1 : 0;
}