From 12ffa72cb254d437e114abe3d0fae88002cd8ea4 Mon Sep 17 00:00:00 2001 From: mihretue Date: Thu, 27 Aug 2026 13:10:46 +0000 Subject: [PATCH] fix(exam): gate Take Exam on confirmed attendance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registrations table mirrored the server's old attendance deny-list — ABSENT/WITHDRAWN/DISQUALIFIED — so the default REGISTERED fell through and "Take exam" appeared before any invigilator had confirmed the candidate was there. It is an allow-list now, PRESENT and LATE only, matching ExamAttemptService.MAY_SIT. A blocked candidate gets a reason rather than an empty cell: "Awaiting attendance" with a tooltip while nobody has called the register, "Not sitting" once attendance was taken and they are not. useExamAttempt does the same for the direct-link path, and translates attendance_not_confirmed / candidate_not_present instead of showing a raw error key on a Start button that cannot work. The server refuses either way — this only makes the refusal legible. ScheduleExamModal no longer promises an admission number: scheduling makes a sitting available, and the candidate registers for it themselves. --- .../components/ScheduleExamModal.tsx | 7 ++-- .../exam-attempt/hooks/useExamAttempt.ts | 25 +++++++++++++- .../exams/pages/ExamsPage/columns.tsx | 34 ++++++++++++++++--- apps/portal/src/app/i18n/locales/am.ts | 4 +++ apps/portal/src/app/i18n/locales/en.ts | 4 +++ 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx b/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx index 641ffc864..320f5a6e2 100644 --- a/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx +++ b/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx @@ -22,6 +22,9 @@ interface Props { * 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, @@ -61,7 +64,7 @@ export function ScheduleExamModal({ {t('review.scheduleExam.intro', { defaultValue: - 'Assign {{applicant}} to a scheduled sitting. They will be notified with the date and their admission number.', + 'Make a sitting available to {{applicant}}. They register for it themselves from the portal.', applicant: applicantName, })} @@ -89,7 +92,7 @@ export function ScheduleExamModal({ {t( 'review.scheduleExam.admissionHint', - 'An admission number is issued automatically when the candidate is seated.', + 'Scheduling does not seat the candidate. They must register for the sitting from the portal, and the admission number is issued then.', )} diff --git a/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts b/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts index e671dba76..baa445600 100644 --- a/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts +++ b/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts @@ -9,6 +9,9 @@ import type { SaveState, } from '../types/exam-attempt'; +/** Mirrors the server's allow-list (ExamAttemptService.MAY_SIT). */ +const MAY_SIT = ['PRESENT', 'LATE']; + const ESSAY_DEBOUNCE_MS = 1500; type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error'; @@ -83,6 +86,19 @@ export function useExamAttempt(examId: string | undefined) { setErrorMessage('You are not registered for this examination.'); return; } + // Attendance gates the sitting, and the exam list already hides "Take + // exam" for these — this is the direct-link path. The server refuses + // either way (ExamAttemptService.MAY_SIT); this only makes the refusal + // legible instead of a raw error key on a Start button that never works. + if (!MAY_SIT.includes(registration.attendanceStatus)) { + setViewState('error'); + setErrorMessage( + registration.attendanceStatus === 'REGISTERED' + ? 'An invigilator must confirm you are present before this exam opens.' + : 'Your attendance record does not permit sitting this examination.', + ); + return; + } if (mineData) { seedFrom(mineData); return; @@ -200,7 +216,14 @@ export function useExamAttempt(examId: string | undefined) { }).unwrap(); seedFrom(result); } catch (error) { - notify.error(extractErrorMessage(error, 'Could not start the exam.')); + const key = extractErrorMessage(error, 'Could not start the exam.'); + notify.error( + key === 'attendance_not_confirmed' + ? 'An invigilator must confirm you are present before this exam opens.' + : key === 'candidate_not_present' + ? 'Your attendance record does not permit sitting this examination.' + : key, + ); } }, [examId, startTrigger, seedFrom]); diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx index 06cf8a4f6..652e72fc3 100644 --- a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx +++ b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx @@ -1,4 +1,4 @@ -import { Badge, Button, Text } from '@mantine/core'; +import { Badge, Button, Text, Tooltip } from '@mantine/core'; import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; @@ -20,7 +20,14 @@ const ATTENDANCE_COLOR: Record = { DISQUALIFIED: 'red', }; -const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED']; +/** + * Attendance rulings that permit sitting the paper — an allow-list mirroring + * the server's (ExamAttemptService.MAY_SIT). The deny-list this replaced named + * only ABSENT/WITHDRAWN/DISQUALIFIED, so the default REGISTERED fell through + * and "Take exam" appeared before any invigilator had confirmed the candidate + * was there. LATE counts: a late arrival is present, just not on time. + */ +const MAY_SIT: AttendanceStatus[] = ['PRESENT', 'LATE']; export function registrationColumns( t: TFunction, @@ -115,9 +122,26 @@ export function registrationColumns( ); } - const eligible = - exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus); - if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null; + if (!deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null; + // Say why the exam is shut rather than rendering an empty cell: the + // candidate is waiting on an invigilator, and silence reads as a bug. + if (row.original.attendanceStatus === 'REGISTERED') { + return ( + + + {t('exams.columns.awaitingAttendance')} + + + ); + } + if (!MAY_SIT.includes(row.original.attendanceStatus)) { + return ( + + {t('exams.columns.notSitting')} + + ); + } + if (exam?.status !== 'ACTIVE') return null; return (