From 6f4878d300e97a68485044907922d37e14620f05 Mon Sep 17 00:00:00 2001 From: mihretue Date: Mon, 31 Aug 2026 07:14:59 +0000 Subject: [PATCH] fix(exam): step the exam form, derive portal status, drop queue overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four screens were telling the applicant things that were not true, and two back-office actions were writing state nobody should be able to write by hand. The exam form is a stepper. Basic Info and Settings each answer only for their own fields, and a third step reviews the whole thing before anything is sent — "Create Exam" exists on that step alone, so there is no path from an earlier one into the API. Clicking it from Basic Info used to report the Settings fields as missing: a correct error the user could not act on, since that step had not been shown yet. The rules move to ./validation, which is what the per-step check and the final whole-payload check both read, so the two cannot drift apart. Back keeps everything entered — state lives in the component. The portal derives the examination stage from the records rather than from the application status alone. Registering, attendance and the sitting itself all happen on the registration and the attempt, and the portal read none of them — which is how an application still said "Awaiting Exam Date" after the back office had already evaluated the paper. examStageFor() names what the records add up to: eligible to register, registered, attendance confirmed, sitting, under evaluation, passed, failed. Both the applications list and the certificates page use it, and both poll the registrations they read it from, because attendance is recorded by an invigilator while the candidate is watching the screen. EXAM_PAID is no longer a waiting state. Nobody assigns a date: the fee having cleared is exactly what makes the candidate eligible to pick a published sitting, so that row now offers Register rather than a disabled button. Schedule Exam and Record Exam Outcome are gone from the COC queue — actions, modals, RTK endpoints and strings. Their server endpoints are gone too, so this is not a hidden button over a live route. --- .../features/exam/pages/ExamPage/index.tsx | 203 +++++++++++++----- .../exam/pages/ExamPage/validation.spec.ts | 88 ++++++++ .../exam/pages/ExamPage/validation.ts | 65 ++++++ .../components/ScheduleExamModal.tsx | 110 ---------- .../features/license-review/config/actions.ts | 35 +-- .../pages/LicenseReviewPage/index.tsx | 118 +--------- apps/backoffice/src/app/i18n/locales/am.ts | 6 +- apps/backoffice/src/app/i18n/locales/en.ts | 6 +- .../certificates/pages/CertificatesPage.tsx | 40 +++- .../features/exams/pages/ExamsPage/index.tsx | 2 + .../licensing/components/ExamStageActions.tsx | 32 ++- .../app/features/licensing/exam-stage.spec.ts | 102 +++++++++ .../src/app/features/licensing/exam-stage.ts | 68 ++++++ .../pages/MyApplicationsPage/actions.tsx | 2 + .../pages/MyApplicationsPage/columns.tsx | 5 +- .../pages/MyApplicationsPage/index.tsx | 38 +++- apps/portal/src/app/i18n/locales/am.ts | 9 + apps/portal/src/app/i18n/locales/en.ts | 13 ++ .../lib/features/licensing/licensing-api.ts | 48 +---- .../features/licensing/licensing.helpers.ts | 4 +- .../lib/features/licensing/licensing.types.ts | 9 - 21 files changed, 623 insertions(+), 380 deletions(-) create mode 100644 apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts create mode 100644 apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts delete mode 100644 apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx create mode 100644 apps/portal/src/app/features/licensing/exam-stage.spec.ts create mode 100644 apps/portal/src/app/features/licensing/exam-stage.ts diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx index c4578d308..4ae3fd48e 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -11,8 +11,9 @@ import { Card, Select, NumberInput, - Tabs, + Stepper, SimpleGrid, + Paper, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; @@ -20,6 +21,7 @@ import { IconPlus, IconInfoCircle, IconClipboardList, + IconChecklist, } from "@tabler/icons-react"; import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui"; import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; @@ -32,10 +34,28 @@ import { useDeleteExamMutation, } from "../../api/exam-api"; import type { Exam } from "../../types/exam"; +import { + LAST_STEP, + stepOfError, + validateAll, + validateStep, + type ExamFormValues, +} from "./validation"; import { examColumns } from "./columns"; import { examActionsColumn } from "./actions"; import { ErrorState, PageHeader } from '@ema-platform/ui'; +function ReviewRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+ + {label} + + {value || "—"} +
+ ); +} + function ExamForm({ editing, certOptions, @@ -77,45 +97,48 @@ function ExamForm({ editing?.cuttingPoint ?? 0, ); const [status, setStatus] = useState(editing?.status ?? null); - const [activeTab, setActiveTab] = useState("basic"); + const [step, setStep] = useState(0); /** - * Split per tab so "Next" can check just the tab in front of the user. - * Submitting from Basic Info used to complain about Settings fields the - * user had not been shown yet — the error was correct and unactionable at - * the same time. Each returns the message key for what is missing, or null. + * Validation is per step, so "Next" only ever complains about what is in + * front of the user. Submitting from Basic Info used to report the Settings + * fields as missing — correct and unactionable at the same time, since that + * step had not been shown yet. The rules live in ./validation so the + * per-step check and the final one cannot drift apart. */ - const validateBasic = (): string | null => { - if (!certificationId || !titleEn || !titleAm || !date || !venue) { - return "exam.form.fillRequiredBasic"; - } - if ((directionEn || directionAm) && !(directionEn && directionAm)) { - return "exam.form.directionBothLanguages"; - } - return null; + const values: ExamFormValues = { + certificationId, + titleEn, + titleAm, + directionEn, + directionAm, + date, + venue, + type, + form, + adminMethod, + evalMethod, + cuttingPoint, }; - const validateSettings = (): string | null => - !type || !form || !adminMethod || !evalMethod || !cuttingPoint - ? "exam.form.fillRequiredSettings" - : null; - const goNext = () => { - const error = validateBasic(); + const error = validateStep(step, values); if (error) { notify.error(t(error)); return; } - setActiveTab("settings"); + setStep((current) => current + 1); }; + /** No validation going backwards — state lives in this component, so + * nothing entered is lost either way. */ + const goBack = () => setStep((current) => Math.max(0, current - 1)); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - // Still checks both: the tabs are clickable, so a user can reach Settings - // without going through Next. - const error = validateBasic() ?? validateSettings(); + const error = validateAll(values); if (error) { - setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic"); + setStep(stepOfError(error)); notify.error(t(error)); return; } @@ -146,20 +169,23 @@ function ExamForm({ return (
- - - }> - {t("exam.form.basicInfo")} - - } - > - {t("exam.form.settings")} - - - - + {/* Stepper, not Tabs: the form is a sequence with a submit at the end, + and the header has to say which step of how many rather than + offering all of them at once. `allowStepSelect` on visited steps + only — jumping ahead is what let the old form be submitted from + Basic Info. */} + + } + allowStepSelect={step > 0} + > )} - - + + + {/* Read-only by design: the last step is for checking what is about + to be created, not a third place to edit it. Back returns to the + step that owns any field that looks wrong. */} + } + allowStepSelect={false} + > + + + {t("exam.form.reviewHint")} + + + + c.value === certificationId)?.label + } + /> + + + + + + + + + + + + + + + + - {activeTab === "basic" ? ( - /* Not type="submit": Basic Info is not the last step, so the - primary action advances rather than saves. */ + {step > 0 && ( + + )} + {step < LAST_STEP ? ( + /* Deliberately not type="submit": only the last step submits, so + there is no path from an earlier one into the API. */ ) : ( - <> - - - + )}
diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts new file mode 100644 index 000000000..1f85f4060 --- /dev/null +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { + LAST_STEP, + stepOfError, + validateAll, + validateBasic, + validateSettings, + validateStep, + type ExamFormValues, +} from './validation'; + +const complete: ExamFormValues = { + certificationId: 'cert-1', + titleEn: 'Master Mariner', + titleAm: 'ካፒቴን', + directionEn: '', + directionAm: '', + date: '2026-09-10', + venue: 'Addis Ababa', + type: 'WRITTEN', + form: 'CHOICE', + adminMethod: 'ONLINE', + evalMethod: 'SUM', + cuttingPoint: 50, +}; + +const basicOnly: ExamFormValues = { + ...complete, + type: null, + form: null, + adminMethod: null, + evalMethod: null, + cuttingPoint: 0, +}; + +/** + * Clicking "Create Exam" from Basic Info used to report the Settings fields as + * missing — a correct error the user could not act on, since that step had not + * been shown yet. Each step now answers only for its own fields. + */ +describe('exam form step validation', () => { + it('keeps an incomplete Basic Info on its own step', () => { + const error = validateStep(0, { ...basicOnly, venue: '' }); + + expect(error).toBe('exam.form.fillRequiredBasic'); + expect(stepOfError(error!)).toBe(0); + }); + + it('does not fault Basic Info for Settings fields that are still empty', () => { + expect(validateStep(0, basicOnly)).toBeNull(); + }); + + it('wants a direction in both languages or in neither', () => { + expect(validateBasic({ ...complete, directionEn: 'Answer all' })).toBe( + 'exam.form.directionBothLanguages', + ); + expect(validateBasic({ ...complete, directionEn: 'Answer all', directionAm: 'ሁሉንም' })).toBeNull(); + }); + + it('keeps an incomplete Settings on its own step', () => { + const error = validateStep(1, basicOnly); + + expect(error).toBe('exam.form.fillRequiredSettings'); + expect(stepOfError(error!)).toBe(1); + }); + + it('lets a complete Settings through to the review step', () => { + expect(validateStep(1, complete)).toBeNull(); + }); + + it('asks nothing of the review step itself — it has no fields', () => { + expect(validateStep(LAST_STEP, basicOnly)).toBeNull(); + }); + + it('checks the whole payload on submit, not just the last step', () => { + // Reachable: the stepper header can be clicked back to a visited step, so + // arriving at Review is no proof everything is still filled in. + expect(validateAll(basicOnly)).toBe('exam.form.fillRequiredSettings'); + expect(validateAll({ ...complete, titleAm: '' })).toBe('exam.form.fillRequiredBasic'); + expect(validateAll(complete)).toBeNull(); + }); + + it('treats a zero cutting point as unset — a paper nobody can fail', () => { + expect(validateSettings({ ...complete, cuttingPoint: 0 })).toBe( + 'exam.form.fillRequiredSettings', + ); + }); +}); diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts new file mode 100644 index 000000000..43bda456e --- /dev/null +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts @@ -0,0 +1,65 @@ +/** + * 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; + 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'; + } + 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; +} diff --git a/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx b/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx deleted file mode 100644 index 320f5a6e2..000000000 --- a/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useState } from 'react'; -import { Alert, Button, Modal, Select, Stack, Text } from '@mantine/core'; -import { IconCalendarEvent } from '@tabler/icons-react'; -import { useTranslation } from 'react-i18next'; -import { ModalFooter } from '@ema-platform/ui'; -import { useGetEligibleExamsQuery } from '@ema-platform/api'; - -interface Props { - opened: boolean; - applicationId: string; - applicantName: string; - loading: boolean; - onClose: () => void; - onConfirm: (payload: { examId: string; examDate?: string }) => void; -} - -/** - * Places a candidate who has paid the examination fee into an existing sitting. - * - * Sessions are picked from the exam calendar rather than typed, because the - * candidate joins a scheduled sitting — this is an assignment, not the creation - * of a per-candidate appointment. Scoped to sittings whose certification - * matches this application's rank, so a Chief Mate candidate cannot be seated - * into an OOW Deck sitting by accident. - * - * Scheduling makes the sitting available; it does not register the candidate. - * That is their own act, from the portal's Register button. - */ -export function ScheduleExamModal({ - opened, - applicationId, - applicantName, - loading, - onClose, - onConfirm, -}: Props) { - const { t } = useTranslation(); - const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened }); - const [examId, setExamId] = useState(null); - - const options = (exams ?? []).map((exam) => ({ - value: exam.id, - label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date] - .filter(Boolean) - .join(' — '), - })); - const selected = exams?.find((exam) => exam.id === examId); - - function confirm() { - if (!examId) return; - onConfirm({ - examId, - examDate: selected?.date ? String(selected.date) : undefined, - }); - } - - return ( - - - - {t('review.scheduleExam.intro', { - defaultValue: - 'Make a sitting available to {{applicant}}. They register for it themselves from the portal.', - applicant: applicantName, - })} - - - {!isLoading && options.length === 0 ? ( - }> - {t( - 'review.scheduleExam.noSessions', - 'No exam sessions for this rank exist yet. Create one in the Exams area first.', - )} - - ) : ( -