mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
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>
119 lines
3.3 KiB
TypeScript
119 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|