diff --git a/apps/portal/src/app/features/exam-attempt/components/ExamCompletion.tsx b/apps/portal/src/app/features/exam-attempt/components/ExamCompletion.tsx new file mode 100644 index 000000000..7f284a623 --- /dev/null +++ b/apps/portal/src/app/features/exam-attempt/components/ExamCompletion.tsx @@ -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 ( + + + + + {expired ? : } + + + {expired ? 'Time expired' : 'Exam submitted'} + + + {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. + + {submittedAt && ( + + {expired ? 'Closed' : 'Submitted'} at {new Date(submittedAt).toLocaleString()} + + )} + + + + + ); +} diff --git a/apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx b/apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx new file mode 100644 index 000000000..e0bfa2c87 --- /dev/null +++ b/apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx @@ -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 ( + + {localized(exam?.title) || 'Examination'} + {localized(exam?.certification?.name)} + + + + + Admission number + {registration.admissionNumber} + + + Session date + {showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''} + + + Duration + }> + {formatDuration(exam?.givenTime)} + + + + Attempt + + {registration.kind === 'RETAKE' + ? `Retake · sitting ${registration.attemptNumber}` + : 'First sitting'} + + + + + + {exam?.direction && localized(exam.direction) && ( + } color="blue" variant="light" title="Instructions"> + {localized(exam.direction)} + + )} + + } 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. + + + {!canStart && ( + + This session is not currently open for candidates to begin. + + )} + + + + + + ); +} diff --git a/apps/portal/src/app/features/exam-attempt/components/ExamQuestionDisplay.tsx b/apps/portal/src/app/features/exam-attempt/components/ExamQuestionDisplay.tsx new file mode 100644 index 000000000..24570fe45 --- /dev/null +++ b/apps/portal/src/app/features/exam-attempt/components/ExamQuestionDisplay.tsx @@ -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 Saving…; + } + if (state === 'saved') { + return ( + + + Saved + + ); + } + if (state === 'error') { + return ( + + + Not saved + + + ); + } + 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 ( + + + + Question {index + 1} of {total} · {question.points} pts + + + + + + {localized(question.title)} + + + {question.form === 'CHOICE' ? ( + + + {question.options + .slice() + .sort((a, b) => a.order - b.order) + .map((option) => ( + + + + {localized(option.text)} + + + ))} + + + ) : ( +