mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12: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>
58 lines
1.7 KiB
TypeScript
58 lines
1.7 KiB
TypeScript
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>
|
|
);
|
|
}
|