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 (