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.', - )} - - ) : ( -