fix(exam): gate Take Exam on confirmed attendance

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.
This commit is contained in:
mihretue
2026-08-27 13:10:46 +00:00
parent 5ad43ba760
commit 12ffa72cb2
5 changed files with 66 additions and 8 deletions

View File

@@ -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]);

View File

@@ -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<AttendanceStatus, string> = {
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(
</Badge>
);
}
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 (
<Tooltip label={t('exams.columns.awaitingAttendanceHint')} multiline w={240}>
<Badge size="sm" variant="light" color="gray">
{t('exams.columns.awaitingAttendance')}
</Badge>
</Tooltip>
);
}
if (!MAY_SIT.includes(row.original.attendanceStatus)) {
return (
<Badge size="sm" variant="light" color="orange">
{t('exams.columns.notSitting')}
</Badge>
);
}
if (exam?.status !== 'ACTIVE') return null;
return (
<Button
size="compact-xs"

View File

@@ -978,6 +978,10 @@ export const am: Translations = {
timeExpired: 'ጊዜው አልቋል',
resumeExam: 'ፈተና ይቀጥሉ',
takeExam: 'ፈተና ይውሰዱ',
awaitingAttendance: 'መገኘት በመጠባበቅ ላይ',
awaitingAttendanceHint:
'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
notSitting: 'አይፈተኑም',
attendanceStatus: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',

View File

@@ -980,6 +980,10 @@ export const en = {
timeExpired: 'Time expired',
resumeExam: 'Resume exam',
takeExam: 'Take exam',
awaitingAttendance: 'Awaiting attendance',
awaitingAttendanceHint:
'An invigilator must confirm you are present before the exam opens.',
notSitting: 'Not sitting',
attendanceStatus: {
REGISTERED: 'Not called',
PRESENT: 'Present',