mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat(exam): candidate exam-taking experience (Phase 4)
New portal feature apps/portal/src/app/features/exam-attempt/, following the repo's existing folder convention (pages/<Page>/index.tsx, components/, types/, hooks/ — matches the payments feature's hooks/ precedent): - types/exam-attempt.ts — response/local-state shapes - hooks/useExamAttempt.ts — all API orchestration: load (registration + attempt), start/resume, per-question autosave (immediate on MCQ select, debounced+flushed-on-navigation for essay text), local countdown seeded from the server's serverTime/remainingSeconds, submit, and resync from the server whenever a write is refused as expired/already-submitted - components/ExamInstructions, ExamTimer, ExamQuestionNav, ExamQuestionDisplay, ExamCompletion — one concern per file, not a single page dump - pages/ExamAttemptPage — thin view layer over the hook Flow: /exams (existing) gets a 'Take exam' action on eligible registration rows → /exams/:examId/take, which shows instructions before an attempt exists, the live exam screen while IN_PROGRESS, and a no-score completion screen once SUBMITTED/EXPIRED (never fabricates a result — grading doesn't exist yet). Security note: RequirePermission on the route and disabled inputs after local timeout are UI conveniences only. Every save/submit is independently re-checked by the backend's ownership + applyExpiry() on each call: a client that skipped the UI entirely and hit the API directly would be bound by exactly the same rules. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button, Card, Stack, Text, ThemeIcon, Title } from '@mantine/core';
|
||||||
|
import { IconCircleCheck, IconClockPause } from '@tabler/icons-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { AttemptStatus } from '../types/exam-attempt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No score, no pass/fail, nothing evaluation-shaped — grading hasn't run.
|
||||||
|
* This only confirms what actually happened: the candidate submitted, or
|
||||||
|
* the deadline closed the attempt out first.
|
||||||
|
*/
|
||||||
|
export function ExamCompletion({
|
||||||
|
status,
|
||||||
|
submittedAt,
|
||||||
|
}: {
|
||||||
|
status: AttemptStatus;
|
||||||
|
submittedAt: string | null;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const expired = status === 'EXPIRED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack maw={520} mx="auto" align="center" py="xl">
|
||||||
|
<Card withBorder radius="lg" p="xl" w="100%">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<ThemeIcon size={64} radius="xl" variant="light" color={expired ? 'orange' : 'teal'}>
|
||||||
|
{expired ? <IconClockPause size={32} /> : <IconCircleCheck size={32} />}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={3} ta="center">
|
||||||
|
{expired ? 'Time expired' : 'Exam submitted'}
|
||||||
|
</Title>
|
||||||
|
<Text ta="center" c="dimmed">
|
||||||
|
{expired
|
||||||
|
? 'The scheduled time ran out. Your saved answers were recorded as your final submission.'
|
||||||
|
: 'Your answers have been recorded.'}
|
||||||
|
{' '}Your result will appear on the Examinations page once marking, moderation and
|
||||||
|
approval are complete — it is not available yet.
|
||||||
|
</Text>
|
||||||
|
{submittedAt && (
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{expired ? 'Closed' : 'Submitted'} at {new Date(submittedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button variant="light" onClick={() => navigate('/exams')}>
|
||||||
|
Back to Examinations
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Alert, Badge, Button, Card, Group, Stack, Text, Title } from '@mantine/core';
|
||||||
|
import { IconAlertCircle, IconClock, IconInfoCircle, IconPlayerPlay } from '@tabler/icons-react';
|
||||||
|
import type { Bilingual } from '@ema-platform/api';
|
||||||
|
import type { EstimatedTime, RegistrationWithExam } from '../types/exam-attempt';
|
||||||
|
|
||||||
|
function formatDuration(time: EstimatedTime | null | undefined): string {
|
||||||
|
if (!time) return 'Not configured';
|
||||||
|
const parts = [
|
||||||
|
time.days ? `${time.days}d` : null,
|
||||||
|
time.hours ? `${time.hours}h` : null,
|
||||||
|
time.minutes ? `${time.minutes}m` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length ? parts.join(' ') : '0m';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExamInstructions({
|
||||||
|
registration,
|
||||||
|
localized,
|
||||||
|
showDate,
|
||||||
|
starting,
|
||||||
|
onStart,
|
||||||
|
}: {
|
||||||
|
registration: RegistrationWithExam;
|
||||||
|
localized: (value: Bilingual | undefined) => string;
|
||||||
|
showDate: (value: string | null | undefined) => string;
|
||||||
|
starting: boolean;
|
||||||
|
onStart: () => void;
|
||||||
|
}) {
|
||||||
|
const exam = registration.exam;
|
||||||
|
const canStart = exam?.status === 'ACTIVE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack maw={720} mx="auto" gap="md">
|
||||||
|
<Title order={2}>{localized(exam?.title) || 'Examination'}</Title>
|
||||||
|
<Text c="dimmed">{localized(exam?.certification?.name)}</Text>
|
||||||
|
|
||||||
|
<Card withBorder radius="md" p="lg">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz="sm" c="dimmed">Admission number</Text>
|
||||||
|
<Text fz="sm" fw={600} ff="monospace">{registration.admissionNumber}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz="sm" c="dimmed">Session date</Text>
|
||||||
|
<Text fz="sm">{showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz="sm" c="dimmed">Duration</Text>
|
||||||
|
<Badge variant="light" leftSection={<IconClock size={12} />}>
|
||||||
|
{formatDuration(exam?.givenTime)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz="sm" c="dimmed">Attempt</Text>
|
||||||
|
<Badge variant="light" color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}>
|
||||||
|
{registration.kind === 'RETAKE'
|
||||||
|
? `Retake · sitting ${registration.attemptNumber}`
|
||||||
|
: 'First sitting'}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{exam?.direction && localized(exam.direction) && (
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light" title="Instructions">
|
||||||
|
{localized(exam.direction)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light">
|
||||||
|
Once started, the timer cannot be paused. Answers are saved automatically as you go, but
|
||||||
|
the exam ends the moment the deadline passes, whether or not you have submitted.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{!canStart && (
|
||||||
|
<Alert color="gray" variant="light">
|
||||||
|
This session is not currently open for candidates to begin.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
leftSection={<IconPlayerPlay size={16} />}
|
||||||
|
loading={starting}
|
||||||
|
disabled={!canStart}
|
||||||
|
onClick={onStart}
|
||||||
|
>
|
||||||
|
Start exam
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Badge, Button, Group, Paper, Radio, Stack, Text, Textarea } from '@mantine/core';
|
||||||
|
import { IconAlertCircle, IconCheck, IconRefresh } from '@tabler/icons-react';
|
||||||
|
import type { Bilingual } from '@ema-platform/api';
|
||||||
|
import type { CandidateQuestion, SaveState } from '../types/exam-attempt';
|
||||||
|
|
||||||
|
function SaveIndicator({ state, onRetry }: { state: SaveState; onRetry: () => void }) {
|
||||||
|
if (state === 'saving') {
|
||||||
|
return <Text fz="xs" c="dimmed">Saving…</Text>;
|
||||||
|
}
|
||||||
|
if (state === 'saved') {
|
||||||
|
return (
|
||||||
|
<Group gap={4}>
|
||||||
|
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal">Saved</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'error') {
|
||||||
|
return (
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconAlertCircle size={13} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text fz="xs" c="red">Not saved</Text>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconRefresh size={12} />}
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one question — never the answer key, because the API response
|
||||||
|
* this reads from (`CandidateQuestion`/`CandidateOption`) has no such field
|
||||||
|
* to render even by mistake.
|
||||||
|
*/
|
||||||
|
export function ExamQuestionDisplay({
|
||||||
|
question,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
localized,
|
||||||
|
selectedOptionId,
|
||||||
|
answerText,
|
||||||
|
saveState,
|
||||||
|
disabled,
|
||||||
|
onSelectOption,
|
||||||
|
onChangeText,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
question: CandidateQuestion;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
localized: (value: Bilingual | undefined) => string;
|
||||||
|
selectedOptionId: string | null | undefined;
|
||||||
|
answerText: string | null | undefined;
|
||||||
|
saveState: SaveState;
|
||||||
|
disabled: boolean;
|
||||||
|
onSelectOption: (optionId: string) => void;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="lg">
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Badge variant="light" color="gray">
|
||||||
|
Question {index + 1} of {total} · {question.points} pts
|
||||||
|
</Badge>
|
||||||
|
<SaveIndicator state={saveState} onRetry={onRetry} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text fz="md" fw={500} mb="lg">
|
||||||
|
{localized(question.title)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{question.form === 'CHOICE' ? (
|
||||||
|
<Radio.Group
|
||||||
|
value={selectedOptionId ?? ''}
|
||||||
|
onChange={onSelectOption}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{question.options
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((option) => (
|
||||||
|
<Radio.Card
|
||||||
|
key={option.id}
|
||||||
|
value={option.id}
|
||||||
|
disabled={disabled}
|
||||||
|
p="sm"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<Group wrap="nowrap" gap="sm">
|
||||||
|
<Radio.Indicator disabled={disabled} />
|
||||||
|
<Text fz="sm">{localized(option.text)}</Text>
|
||||||
|
</Group>
|
||||||
|
</Radio.Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
placeholder="Write your answer"
|
||||||
|
minRows={8}
|
||||||
|
autosize
|
||||||
|
disabled={disabled}
|
||||||
|
value={answerText ?? ''}
|
||||||
|
onChange={(event) => onChangeText(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Paper, SimpleGrid, Text, UnstyledButton } from '@mantine/core';
|
||||||
|
import type { CandidateQuestion } from '../types/exam-attempt';
|
||||||
|
|
||||||
|
export function ExamQuestionNav({
|
||||||
|
questions,
|
||||||
|
currentIndex,
|
||||||
|
answeredIds,
|
||||||
|
disabled,
|
||||||
|
onJump,
|
||||||
|
}: {
|
||||||
|
questions: CandidateQuestion[];
|
||||||
|
currentIndex: number;
|
||||||
|
answeredIds: Set<string>;
|
||||||
|
disabled: boolean;
|
||||||
|
onJump: (index: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="sm">
|
||||||
|
<Text fz="xs" fw={600} c="dimmed" mb="xs" tt="uppercase">
|
||||||
|
Questions
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={5} spacing={6}>
|
||||||
|
{questions.map((q, index) => {
|
||||||
|
const answered = answeredIds.has(q.id);
|
||||||
|
const current = index === currentIndex;
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
key={q.id}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onJump(index)}
|
||||||
|
style={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
border: current ? '2px solid var(--mantine-color-blue-6)' : '1px solid var(--mantine-color-gray-4)',
|
||||||
|
background: answered
|
||||||
|
? 'var(--mantine-color-teal-1)'
|
||||||
|
: 'var(--mantine-color-body)',
|
||||||
|
color: answered ? 'var(--mantine-color-teal-8)' : undefined,
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Text fz="xs" c="dimmed" mt="sm">
|
||||||
|
{answeredIds.size} of {questions.length} answered
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Badge, Group } from '@mantine/core';
|
||||||
|
import { IconClock } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
function format(totalSeconds: number): string {
|
||||||
|
const s = Math.max(0, totalSeconds);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display only. `remainingSeconds` is a local countdown seeded once from the
|
||||||
|
* server's own clock (`AttemptSession.remainingSeconds`/`serverTime`) and
|
||||||
|
* ticked down client-side — the deadline it represents is enforced by the
|
||||||
|
* backend on every save/submit regardless of whether this number is right.
|
||||||
|
*/
|
||||||
|
export function ExamTimer({ remainingSeconds }: { remainingSeconds: number }) {
|
||||||
|
const low = remainingSeconds <= 300; // 5 minutes
|
||||||
|
return (
|
||||||
|
<Group gap={6}>
|
||||||
|
<Badge
|
||||||
|
size="lg"
|
||||||
|
variant="light"
|
||||||
|
color={low ? 'red' : 'blue'}
|
||||||
|
leftSection={<IconClock size={14} />}
|
||||||
|
ff="monospace"
|
||||||
|
>
|
||||||
|
{format(remainingSeconds)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useApiMutation, useApiQuery, extractErrorMessage } from '@ema-platform/api';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import type {
|
||||||
|
AttemptSession,
|
||||||
|
CandidateAnswer,
|
||||||
|
ExamAttempt,
|
||||||
|
RegistrationWithExam,
|
||||||
|
SaveState,
|
||||||
|
} from '../types/exam-attempt';
|
||||||
|
|
||||||
|
const ESSAY_DEBOUNCE_MS = 1500;
|
||||||
|
|
||||||
|
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
|
||||||
|
|
||||||
|
type LocalAnswer = { selectedOptionId?: string | null; answerText?: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All state and API orchestration for taking one exam. Kept out of the page
|
||||||
|
* component so the component tree stays about rendering, not about save
|
||||||
|
* timers and expiry races.
|
||||||
|
*
|
||||||
|
* Nothing here is a security boundary — every write still goes through the
|
||||||
|
* backend's own ownership + `applyExpiry()` checks on every call. This hook
|
||||||
|
* only decides what to show; a client that skipped straight to calling the
|
||||||
|
* API directly would hit exactly the same server-side rules.
|
||||||
|
*/
|
||||||
|
export function useExamAttempt(examId: string | undefined) {
|
||||||
|
const [session, setSession] = useState<AttemptSession | null>(null);
|
||||||
|
const [viewState, setViewState] = useState<ViewState>('loading');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [answers, setAnswers] = useState<Record<string, LocalAnswer>>({});
|
||||||
|
const [saveStates, setSaveStates] = useState<Record<string, SaveState>>({});
|
||||||
|
const [remainingSeconds, setRemainingSeconds] = useState(0);
|
||||||
|
|
||||||
|
const answersRef = useRef(answers);
|
||||||
|
answersRef.current = answers;
|
||||||
|
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||||
|
const seeded = useRef(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: registrations,
|
||||||
|
isLoading: loadingRegistrations,
|
||||||
|
} = useApiQuery<RegistrationWithExam[]>({ url: '/exams/registrations/mine' });
|
||||||
|
const registration = registrations?.find((r) => r.exam?.id === examId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: mineData,
|
||||||
|
isLoading: loadingMine,
|
||||||
|
isError: mineIsError,
|
||||||
|
error: mineError,
|
||||||
|
refetch: refetchMine,
|
||||||
|
} = useApiQuery<AttemptSession>(
|
||||||
|
{ url: `/exam-attempts/mine/${examId}` },
|
||||||
|
{ skip: !examId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [startTrigger, { isLoading: starting }] = useApiMutation<AttemptSession>();
|
||||||
|
const [answerTrigger] = useApiMutation<CandidateAnswer>();
|
||||||
|
const [submitTrigger, { isLoading: submitting }] = useApiMutation<ExamAttempt>();
|
||||||
|
|
||||||
|
const seedFrom = useCallback((data: AttemptSession) => {
|
||||||
|
setSession(data);
|
||||||
|
const map: Record<string, LocalAnswer> = {};
|
||||||
|
for (const a of data.answers) {
|
||||||
|
map[a.questionId] = { selectedOptionId: a.selectedOptionId, answerText: a.answerText };
|
||||||
|
}
|
||||||
|
setAnswers(map);
|
||||||
|
setRemainingSeconds(data.remainingSeconds);
|
||||||
|
setViewState(data.attempt.status === 'IN_PROGRESS' ? 'taking' : 'completed');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Seed once from the initial load — after that, local state (ticking
|
||||||
|
// timer, in-flight edits) is the source of truth, not this query.
|
||||||
|
useEffect(() => {
|
||||||
|
if (seeded.current) return;
|
||||||
|
if (loadingRegistrations || loadingMine) return;
|
||||||
|
seeded.current = true;
|
||||||
|
|
||||||
|
if (!registration) {
|
||||||
|
setViewState('error');
|
||||||
|
setErrorMessage('You are not registered for this examination.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mineData) {
|
||||||
|
seedFrom(mineData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mineIsError) {
|
||||||
|
const key = extractErrorMessage(mineError, '');
|
||||||
|
if (key === 'attempt_not_found') {
|
||||||
|
setViewState('not-started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewState('error');
|
||||||
|
setErrorMessage(extractErrorMessage(mineError, 'Could not load the exam.'));
|
||||||
|
}
|
||||||
|
}, [loadingRegistrations, loadingMine, registration, mineData, mineIsError, mineError, seedFrom]);
|
||||||
|
|
||||||
|
/** Authoritative resync — used after any write is refused as expired/submitted. */
|
||||||
|
const syncFromServer = useCallback(async () => {
|
||||||
|
const result = await refetchMine();
|
||||||
|
if (result.data) {
|
||||||
|
seedFrom(result.data as AttemptSession);
|
||||||
|
} else {
|
||||||
|
setViewState('error');
|
||||||
|
setErrorMessage(extractErrorMessage(result.error, 'The exam session ended.'));
|
||||||
|
}
|
||||||
|
}, [refetchMine, seedFrom]);
|
||||||
|
|
||||||
|
const persistAnswer = useCallback(
|
||||||
|
async (questionId: string, payload: LocalAnswer) => {
|
||||||
|
if (!session) return;
|
||||||
|
setSaveStates((s) => ({ ...s, [questionId]: 'saving' }));
|
||||||
|
try {
|
||||||
|
const saved = await answerTrigger({
|
||||||
|
url: `/exam-attempts/${session.attempt.id}/answers`,
|
||||||
|
method: 'POST',
|
||||||
|
body: { questionId, ...payload },
|
||||||
|
}).unwrap();
|
||||||
|
setAnswers((a) => ({
|
||||||
|
...a,
|
||||||
|
[questionId]: { selectedOptionId: saved.selectedOptionId, answerText: saved.answerText },
|
||||||
|
}));
|
||||||
|
setSaveStates((s) => ({ ...s, [questionId]: 'saved' }));
|
||||||
|
} catch (error) {
|
||||||
|
setSaveStates((s) => ({ ...s, [questionId]: 'error' }));
|
||||||
|
const key = extractErrorMessage(error, '');
|
||||||
|
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
|
||||||
|
notify.error(
|
||||||
|
key === 'attempt_expired'
|
||||||
|
? 'Time is up — this answer was not saved.'
|
||||||
|
: 'This attempt has already been submitted.',
|
||||||
|
);
|
||||||
|
syncFromServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[session, answerTrigger, syncFromServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const flush = useCallback(
|
||||||
|
(questionId: string) => {
|
||||||
|
const timer = debounceTimers.current[questionId];
|
||||||
|
if (!timer) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
delete debounceTimers.current[questionId];
|
||||||
|
const current = answersRef.current[questionId];
|
||||||
|
if (current) persistAnswer(questionId, current);
|
||||||
|
},
|
||||||
|
[persistAnswer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectOption = useCallback(
|
||||||
|
(questionId: string, optionId: string) => {
|
||||||
|
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], selectedOptionId: optionId } }));
|
||||||
|
persistAnswer(questionId, { selectedOptionId: optionId });
|
||||||
|
},
|
||||||
|
[persistAnswer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeText = useCallback(
|
||||||
|
(questionId: string, text: string) => {
|
||||||
|
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], answerText: text } }));
|
||||||
|
setSaveStates((s) => ({ ...s, [questionId]: 'idle' }));
|
||||||
|
clearTimeout(debounceTimers.current[questionId]);
|
||||||
|
debounceTimers.current[questionId] = setTimeout(() => {
|
||||||
|
delete debounceTimers.current[questionId];
|
||||||
|
persistAnswer(questionId, { answerText: text });
|
||||||
|
}, ESSAY_DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
[persistAnswer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goTo = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const current = session?.questions[currentIndex];
|
||||||
|
if (current) flush(current.id);
|
||||||
|
setCurrentIndex(index);
|
||||||
|
},
|
||||||
|
[session, currentIndex, flush],
|
||||||
|
);
|
||||||
|
|
||||||
|
const retry = useCallback(
|
||||||
|
(questionId: string) => {
|
||||||
|
const current = answersRef.current[questionId];
|
||||||
|
if (current) persistAnswer(questionId, current);
|
||||||
|
},
|
||||||
|
[persistAnswer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
if (!examId) return;
|
||||||
|
try {
|
||||||
|
const result = await startTrigger({
|
||||||
|
url: '/exam-attempts/start',
|
||||||
|
method: 'POST',
|
||||||
|
body: { examId },
|
||||||
|
}).unwrap();
|
||||||
|
seedFrom(result);
|
||||||
|
} catch (error) {
|
||||||
|
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
|
||||||
|
}
|
||||||
|
}, [examId, startTrigger, seedFrom]);
|
||||||
|
|
||||||
|
const submit = useCallback(async () => {
|
||||||
|
if (!session) return;
|
||||||
|
const current = session.questions[currentIndex];
|
||||||
|
if (current) flush(current.id);
|
||||||
|
try {
|
||||||
|
const attempt = await submitTrigger({
|
||||||
|
url: `/exam-attempts/${session.attempt.id}/submit`,
|
||||||
|
method: 'POST',
|
||||||
|
}).unwrap();
|
||||||
|
setSession((s) => (s ? { ...s, attempt } : s));
|
||||||
|
setViewState('completed');
|
||||||
|
} catch (error) {
|
||||||
|
const key = extractErrorMessage(error, '');
|
||||||
|
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
|
||||||
|
syncFromServer();
|
||||||
|
} else {
|
||||||
|
notify.error(extractErrorMessage(error, 'Could not submit the exam.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [session, currentIndex, flush, submitTrigger, syncFromServer]);
|
||||||
|
|
||||||
|
/** Local countdown only — every write is still checked server-side regardless. */
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewState !== 'taking') return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setRemainingSeconds((s) => {
|
||||||
|
if (s <= 1) {
|
||||||
|
clearInterval(id);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return s - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [viewState]);
|
||||||
|
|
||||||
|
// Time reaching zero locally: stop taking input, tell the server, then
|
||||||
|
// trust whatever it reports back over anything computed in the browser.
|
||||||
|
const timedOutRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewState !== 'taking' || remainingSeconds > 0 || timedOutRef.current) return;
|
||||||
|
timedOutRef.current = true;
|
||||||
|
notify.error("Time's up.");
|
||||||
|
// submit() itself resyncs from the server if this loses the race against
|
||||||
|
// applyExpiry() — either way the final state comes from the backend, not
|
||||||
|
// from this timer having reached zero.
|
||||||
|
submit();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [remainingSeconds, viewState]);
|
||||||
|
|
||||||
|
const answeredIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
Object.entries(answers)
|
||||||
|
.filter(([, v]) => v.selectedOptionId || v.answerText?.trim())
|
||||||
|
.map(([id]) => id),
|
||||||
|
),
|
||||||
|
[answers],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewState,
|
||||||
|
errorMessage,
|
||||||
|
registration,
|
||||||
|
session,
|
||||||
|
currentIndex,
|
||||||
|
answers,
|
||||||
|
saveStates,
|
||||||
|
answeredIds,
|
||||||
|
remainingSeconds,
|
||||||
|
starting,
|
||||||
|
submitting,
|
||||||
|
start,
|
||||||
|
selectOption,
|
||||||
|
changeText,
|
||||||
|
goTo,
|
||||||
|
retry,
|
||||||
|
submit,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Alert, Button, Center, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
||||||
|
import { IconAlertCircle, IconSend } from '@tabler/icons-react';
|
||||||
|
import { useLocalized } from '@ema-platform/api';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
import { useExamAttempt } from '../../hooks/useExamAttempt';
|
||||||
|
import { ExamInstructions } from '../../components/ExamInstructions';
|
||||||
|
import { ExamTimer } from '../../components/ExamTimer';
|
||||||
|
import { ExamQuestionNav } from '../../components/ExamQuestionNav';
|
||||||
|
import { ExamQuestionDisplay } from '../../components/ExamQuestionDisplay';
|
||||||
|
import { ExamCompletion } from '../../components/ExamCompletion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate exam-taking screen (Phase 4). Route: `/exams/:examId/take`.
|
||||||
|
*
|
||||||
|
* All state/API orchestration lives in `useExamAttempt` — this component is
|
||||||
|
* the view: pick which of loading/not-started/taking/completed/error to
|
||||||
|
* render. Every write it triggers (start, save, submit) is re-checked by the
|
||||||
|
* backend regardless of what this screen currently shows; nothing here is
|
||||||
|
* the actual security boundary.
|
||||||
|
*/
|
||||||
|
export function ExamAttemptPage() {
|
||||||
|
const { examId } = useParams<{ examId: string }>();
|
||||||
|
const localized = useLocalized();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
const [confirmOpened, setConfirmOpened] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
viewState,
|
||||||
|
errorMessage,
|
||||||
|
registration,
|
||||||
|
session,
|
||||||
|
currentIndex,
|
||||||
|
answers,
|
||||||
|
saveStates,
|
||||||
|
answeredIds,
|
||||||
|
remainingSeconds,
|
||||||
|
starting,
|
||||||
|
submitting,
|
||||||
|
start,
|
||||||
|
selectOption,
|
||||||
|
changeText,
|
||||||
|
goTo,
|
||||||
|
retry,
|
||||||
|
submit,
|
||||||
|
} = useExamAttempt(examId);
|
||||||
|
|
||||||
|
if (viewState === 'loading') {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewState === 'error') {
|
||||||
|
return (
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="red" maw={600} mx="auto" mt="xl">
|
||||||
|
{errorMessage}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewState === 'not-started') {
|
||||||
|
if (!registration) return null; // guarded by 'error' above, appeases TS
|
||||||
|
return (
|
||||||
|
<ExamInstructions
|
||||||
|
registration={registration}
|
||||||
|
localized={localized}
|
||||||
|
showDate={showDate}
|
||||||
|
starting={starting}
|
||||||
|
onStart={start}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewState === 'completed' && session) {
|
||||||
|
return (
|
||||||
|
<ExamCompletion status={session.attempt.status} submittedAt={session.attempt.submittedAt} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) return null; // 'taking' always has a session by construction
|
||||||
|
|
||||||
|
const question = session.questions[currentIndex];
|
||||||
|
const answer = answers[question.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack maw={1000} mx="auto" gap="md">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Text fw={600}>{localized(registration?.exam?.title) || 'Examination in progress'}</Text>
|
||||||
|
<ExamTimer remainingSeconds={remainingSeconds} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group align="flex-start" gap="md" wrap="wrap-reverse">
|
||||||
|
<div style={{ flex: 1, minWidth: 280 }}>
|
||||||
|
<ExamQuestionDisplay
|
||||||
|
question={question}
|
||||||
|
index={currentIndex}
|
||||||
|
total={session.questions.length}
|
||||||
|
localized={localized}
|
||||||
|
selectedOptionId={answer?.selectedOptionId}
|
||||||
|
answerText={answer?.answerText}
|
||||||
|
saveState={saveStates[question.id] ?? 'idle'}
|
||||||
|
disabled={remainingSeconds <= 0}
|
||||||
|
onSelectOption={(optionId) => selectOption(question.id, optionId)}
|
||||||
|
onChangeText={(text) => changeText(question.id, text)}
|
||||||
|
onRetry={() => retry(question.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
disabled={currentIndex === 0}
|
||||||
|
onClick={() => goTo(currentIndex - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
{currentIndex < session.questions.length - 1 ? (
|
||||||
|
<Button onClick={() => goTo(currentIndex + 1)}>Next</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconSend size={15} />}
|
||||||
|
onClick={() => setConfirmOpened(true)}
|
||||||
|
>
|
||||||
|
Submit exam
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: 220, flexShrink: 0 }}>
|
||||||
|
<ExamQuestionNav
|
||||||
|
questions={session.questions}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
answeredIds={answeredIds}
|
||||||
|
disabled={remainingSeconds <= 0}
|
||||||
|
onJump={goTo}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconSend size={15} />}
|
||||||
|
onClick={() => setConfirmOpened(true)}
|
||||||
|
>
|
||||||
|
Submit exam
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={confirmOpened}
|
||||||
|
onClose={() => setConfirmOpened(false)}
|
||||||
|
title="Submit this exam?"
|
||||||
|
radius="lg"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">
|
||||||
|
{answeredIds.size} of {session.questions.length} questions answered. Once submitted,
|
||||||
|
answers cannot be changed.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setConfirmOpened(false)}>
|
||||||
|
Keep working
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
loading={submitting}
|
||||||
|
onClick={async () => {
|
||||||
|
await submit();
|
||||||
|
setConfirmOpened(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExamAttemptPage;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Bilingual } from '@ema-platform/api';
|
||||||
|
|
||||||
|
export type AttemptStatus = 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
|
||||||
|
export type QuestionForm = 'ESSAY' | 'CHOICE';
|
||||||
|
|
||||||
|
export interface CandidateOption {
|
||||||
|
id: string;
|
||||||
|
text: Bilingual;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never carries a correct-answer flag — the API doesn't send one. */
|
||||||
|
export interface CandidateQuestion {
|
||||||
|
id: string;
|
||||||
|
title: Bilingual;
|
||||||
|
form: QuestionForm;
|
||||||
|
points: number;
|
||||||
|
options: CandidateOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamAttempt {
|
||||||
|
id: string;
|
||||||
|
examId: string;
|
||||||
|
registrationId: string;
|
||||||
|
status: AttemptStatus;
|
||||||
|
startedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
submittedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateAnswer {
|
||||||
|
id: string;
|
||||||
|
attemptId: string;
|
||||||
|
questionId: string;
|
||||||
|
selectedOptionId: string | null;
|
||||||
|
answerText: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response shape shared by POST /exam-attempts/start and GET .../mine/:examId. */
|
||||||
|
export interface AttemptSession {
|
||||||
|
attempt: ExamAttempt;
|
||||||
|
questions: CandidateQuestion[];
|
||||||
|
answers: CandidateAnswer[];
|
||||||
|
serverTime: string;
|
||||||
|
remainingSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
export interface EstimatedTime {
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of `GET /exams/registrations/mine`'s response this feature
|
||||||
|
* reads — the endpoint returns the full raw exam/registration, this is
|
||||||
|
* just this feature's own narrow view of it (matches the sibling `exams`
|
||||||
|
* feature's pattern of each screen typing only what it uses).
|
||||||
|
*/
|
||||||
|
export interface RegistrationWithExam {
|
||||||
|
id: string;
|
||||||
|
admissionNumber: string;
|
||||||
|
kind: 'NEW' | 'RETAKE';
|
||||||
|
attemptNumber: number;
|
||||||
|
attendanceStatus: string;
|
||||||
|
exam?: {
|
||||||
|
id: string;
|
||||||
|
title: Bilingual;
|
||||||
|
direction?: Bilingual;
|
||||||
|
date: string;
|
||||||
|
venue: string | null;
|
||||||
|
status: string;
|
||||||
|
givenTime: EstimatedTime | null;
|
||||||
|
certification?: { name?: Bilingual };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, Button, Text } from '@mantine/core';
|
import { Badge, Button, Text } from '@mantine/core';
|
||||||
import { IconFileText, IconGavel } from '@tabler/icons-react';
|
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { Bilingual } from '@ema-platform/api';
|
import type { Bilingual } from '@ema-platform/api';
|
||||||
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
||||||
@@ -19,12 +19,15 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
|||||||
DISQUALIFIED: 'red',
|
DISQUALIFIED: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
|
||||||
|
|
||||||
export function registrationColumns(deps: {
|
export function registrationColumns(deps: {
|
||||||
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||||
can: (required?: string[]) => boolean;
|
can: (required?: string[]) => boolean;
|
||||||
localized: (value: Bilingual | undefined) => string;
|
localized: (value: Bilingual | undefined) => string;
|
||||||
showDate: (value: string | null | undefined) => string;
|
showDate: (value: string | null | undefined) => string;
|
||||||
onDownloadSlip: (registration: MyRegistration) => void;
|
onDownloadSlip: (registration: MyRegistration) => void;
|
||||||
|
onStartExam: (registration: MyRegistration) => void;
|
||||||
}): AdvancedColumn<MyRegistration>[] {
|
}): AdvancedColumn<MyRegistration>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -87,6 +90,25 @@ export function registrationColumns(deps: {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null,
|
) : null,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: 'Exam',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const exam = row.original.exam;
|
||||||
|
const eligible =
|
||||||
|
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
|
||||||
|
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconPlayerPlay size={13} />}
|
||||||
|
onClick={() => deps.onStartExam(row.original)}
|
||||||
|
>
|
||||||
|
Take exam
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -79,6 +80,7 @@ export interface MyAppeal {
|
|||||||
* when a mark looks wrong.
|
* when a mark looks wrong.
|
||||||
*/
|
*/
|
||||||
export function ExamsPage() {
|
export function ExamsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
||||||
@@ -243,6 +245,7 @@ export function ExamsPage() {
|
|||||||
localized,
|
localized,
|
||||||
showDate,
|
showDate,
|
||||||
onDownloadSlip: downloadSlip,
|
onDownloadSlip: downloadSlip,
|
||||||
|
onStartExam: (registration) => navigate(`/exams/${registration.exam?.id}/take`),
|
||||||
})}
|
})}
|
||||||
data={pagedRegistrations.rows}
|
data={pagedRegistrations.rows}
|
||||||
itemCount={pagedRegistrations.itemCount}
|
itemCount={pagedRegistrations.itemCount}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegi
|
|||||||
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
|
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
|
||||||
import { VerifyCertificatePage } from "./features/verify/pages/VerifyCertificatePage";
|
import { VerifyCertificatePage } from "./features/verify/pages/VerifyCertificatePage";
|
||||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||||
|
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
||||||
|
|
||||||
// Phase 1 pages
|
// Phase 1 pages
|
||||||
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||||
@@ -184,6 +185,14 @@ export const router = createBrowserRouter([
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/exams/:examId/take",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.APPLY_EXAM, P.VIEW_OWN_EXAM]}>
|
||||||
|
<ExamAttemptPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
// The public-facing registry was a hardcoded mock and does not belong in
|
// The public-facing registry was a hardcoded mock and does not belong in
|
||||||
// the applicant portal; officers browse seafarers in the backoffice.
|
// the applicant portal; officers browse seafarers in the backoffice.
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user