mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 12:18:12 +00:00
Merge pull request #37 from Tria-plc/feature/exam-attempt-domain
Feature/exam attempt domain
This commit is contained in:
@@ -22,6 +22,9 @@ interface Props {
|
|||||||
* of a per-candidate appointment. Scoped to sittings whose certification
|
* of a per-candidate appointment. Scoped to sittings whose certification
|
||||||
* matches this application's rank, so a Chief Mate candidate cannot be seated
|
* matches this application's rank, so a Chief Mate candidate cannot be seated
|
||||||
* into an OOW Deck sitting by accident.
|
* 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({
|
export function ScheduleExamModal({
|
||||||
opened,
|
opened,
|
||||||
@@ -61,7 +64,7 @@ export function ScheduleExamModal({
|
|||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('review.scheduleExam.intro', {
|
{t('review.scheduleExam.intro', {
|
||||||
defaultValue:
|
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,
|
applicant: applicantName,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -89,7 +92,7 @@ export function ScheduleExamModal({
|
|||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{t(
|
{t(
|
||||||
'review.scheduleExam.admissionHint',
|
'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.',
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type {
|
|||||||
SaveState,
|
SaveState,
|
||||||
} from '../types/exam-attempt';
|
} from '../types/exam-attempt';
|
||||||
|
|
||||||
|
/** Mirrors the server's allow-list (ExamAttemptService.MAY_SIT). */
|
||||||
|
const MAY_SIT = ['PRESENT', 'LATE'];
|
||||||
|
|
||||||
const ESSAY_DEBOUNCE_MS = 1500;
|
const ESSAY_DEBOUNCE_MS = 1500;
|
||||||
|
|
||||||
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
|
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.');
|
setErrorMessage('You are not registered for this examination.');
|
||||||
return;
|
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) {
|
if (mineData) {
|
||||||
seedFrom(mineData);
|
seedFrom(mineData);
|
||||||
return;
|
return;
|
||||||
@@ -200,7 +216,14 @@ export function useExamAttempt(examId: string | undefined) {
|
|||||||
}).unwrap();
|
}).unwrap();
|
||||||
seedFrom(result);
|
seedFrom(result);
|
||||||
} catch (error) {
|
} 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]);
|
}, [examId, startTrigger, seedFrom]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
@@ -20,7 +20,14 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
|||||||
DISQUALIFIED: 'red',
|
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(
|
export function registrationColumns(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
@@ -115,9 +122,26 @@ export function registrationColumns(
|
|||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const eligible =
|
if (!deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||||
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
|
// Say why the exam is shut rather than rendering an empty cell: the
|
||||||
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
// 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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
|
|||||||
@@ -979,6 +979,10 @@ export const am: Translations = {
|
|||||||
timeExpired: 'ጊዜው አልቋል',
|
timeExpired: 'ጊዜው አልቋል',
|
||||||
resumeExam: 'ፈተና ይቀጥሉ',
|
resumeExam: 'ፈተና ይቀጥሉ',
|
||||||
takeExam: 'ፈተና ይውሰዱ',
|
takeExam: 'ፈተና ይውሰዱ',
|
||||||
|
awaitingAttendance: 'መገኘት በመጠባበቅ ላይ',
|
||||||
|
awaitingAttendanceHint:
|
||||||
|
'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
|
||||||
|
notSitting: 'አይፈተኑም',
|
||||||
attendanceStatus: {
|
attendanceStatus: {
|
||||||
REGISTERED: 'አልተጠራም',
|
REGISTERED: 'አልተጠራም',
|
||||||
PRESENT: 'ተገኝቷል',
|
PRESENT: 'ተገኝቷል',
|
||||||
|
|||||||
@@ -981,6 +981,10 @@ export const en = {
|
|||||||
timeExpired: 'Time expired',
|
timeExpired: 'Time expired',
|
||||||
resumeExam: 'Resume exam',
|
resumeExam: 'Resume exam',
|
||||||
takeExam: 'Take exam',
|
takeExam: 'Take exam',
|
||||||
|
awaitingAttendance: 'Awaiting attendance',
|
||||||
|
awaitingAttendanceHint:
|
||||||
|
'An invigilator must confirm you are present before the exam opens.',
|
||||||
|
notSitting: 'Not sitting',
|
||||||
attendanceStatus: {
|
attendanceStatus: {
|
||||||
REGISTERED: 'Not called',
|
REGISTERED: 'Not called',
|
||||||
PRESENT: 'Present',
|
PRESENT: 'Present',
|
||||||
|
|||||||
Reference in New Issue
Block a user