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:
mihretue
2026-08-14 09:17:09 +00:00
parent 14b5bb83f9
commit 4edce751ee
11 changed files with 939 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
};
}

View File

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

View File

@@ -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 };
};
}

View File

@@ -1,5 +1,5 @@
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 { Bilingual } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
@@ -19,12 +19,15 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
DISQUALIFIED: 'red',
};
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
export function registrationColumns(deps: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
onDownloadSlip: (registration: MyRegistration) => void;
onStartExam: (registration: MyRegistration) => void;
}): AdvancedColumn<MyRegistration>[] {
return [
{
@@ -87,6 +90,25 @@ export function registrationColumns(deps: {
</Button>
) : 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>
);
},
},
];
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Badge,
Button,
@@ -79,6 +80,7 @@ export interface MyAppeal {
* when a mark looks wrong.
*/
export function ExamsPage() {
const navigate = useNavigate();
const showDate = useDateDisplayer();
const localized = useLocalized();
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
@@ -243,6 +245,7 @@ export function ExamsPage() {
localized,
showDate,
onDownloadSlip: downloadSlip,
onStartExam: (registration) => navigate(`/exams/${registration.exam?.id}/take`),
})}
data={pagedRegistrations.rows}
itemCount={pagedRegistrations.itemCount}

View File

@@ -32,6 +32,7 @@ import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegi
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
import { VerifyCertificatePage } from "./features/verify/pages/VerifyCertificatePage";
import { ExamsPage } from "./features/exams/pages/ExamsPage";
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
// Phase 1 pages
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
@@ -184,6 +185,14 @@ export const router = createBrowserRouter([
</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 applicant portal; officers browse seafarers in the backoffice.
{