From d98d6a71fe1ef73deb76aebddb3c60a05f20df6a Mon Sep 17 00:00:00 2001 From: mihretue Date: Thu, 13 Aug 2026 17:17:14 +0000 Subject: [PATCH 01/16] fix(result): gate record/save buttons behind RECORD_EXAM_RESULT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record Result (POST /results) and the detail-modal Save (PUT /results/:id) had no RequirePermission gate, unlike every other action on this page — both routes are backend-guarded (RECORD_EXAM_RESULT, or RECORD_EXAM_RESULT|MODERATE_EXAM_RESULT for the update), the buttons just never mirrored it client-side. Co-Authored-By: Claude Sonnet 5 --- .../result/pages/ResultPage/index.tsx | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx index 583ea57d0..b9acb34ca 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx @@ -314,9 +314,11 @@ export function ResultPage() { {t('result.review.publish')} - + + + @@ -504,14 +506,22 @@ export function ResultPage() { - + + ) : ( From 14b5bb83f93fe7dedf434fe7f9d759b4bd75090d Mon Sep 17 00:00:00 2001 From: mihretue Date: Thu, 13 Aug 2026 19:28:03 +0000 Subject: [PATCH 02/16] feat(question): MCQ options authoring editor (Phase 2 domain foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend now supports MCQ options + a separate answer-key table (PUT /questions/:id/options). Frontend side of that, authoring only — no candidate exam-taking UI. - Question type gains options?: QuestionOption[] (no correctness field — the API never returns one, matching the backend's split-table design) - New question-api hooks: getQuestionWithOptions, setQuestionOptions - New QuestionOptionsEditor component (separate file, not inlined into QuestionPage) — add/remove/reorder options, mark correct, bilingual text - Wired into the question edit form, shown only for an existing CHOICE question (options attach to an id, matching the backend's replace endpoint) - en/am i18n strings for the new editor Co-Authored-By: Claude Sonnet 5 --- .../app/features/question/api/question-api.ts | 18 +++ .../components/QuestionOptionsEditor.tsx | 134 ++++++++++++++++++ .../question/pages/QuestionPage/index.tsx | 10 ++ .../app/features/question/types/question.ts | 23 +++ apps/backoffice/src/app/i18n/locales/am.ts | 14 ++ apps/backoffice/src/app/i18n/locales/en.ts | 15 ++ 6 files changed, 214 insertions(+) create mode 100644 apps/backoffice/src/app/features/question/components/QuestionOptionsEditor.tsx diff --git a/apps/backoffice/src/app/features/question/api/question-api.ts b/apps/backoffice/src/app/features/question/api/question-api.ts index a118af749..d84e801f2 100644 --- a/apps/backoffice/src/app/features/question/api/question-api.ts +++ b/apps/backoffice/src/app/features/question/api/question-api.ts @@ -1,10 +1,12 @@ import { baseApi } from '@ema-platform/api'; import type { Question, + QuestionOption, ListResponse, CreateQuestionPayload, UpdateQuestionPayload, ReviewQuestionPayload, + SetQuestionOptionsPayload, } from '../types/question'; const questionApi = baseApi.injectEndpoints({ @@ -17,6 +19,11 @@ const questionApi = baseApi.injectEndpoints({ query: (id) => `/questions/${id}`, providesTags: ['Api'], }), + /** Same question, with `options` populated — the MCQ authoring editor. */ + getQuestionWithOptions: builder.query({ + query: (id) => `/questions/${id}?i=options`, + providesTags: ['Api'], + }), createQuestion: builder.mutation({ query: (body) => ({ url: '/questions', method: 'POST', body }), invalidatesTags: ['Api'], @@ -47,6 +54,15 @@ const questionApi = baseApi.injectEndpoints({ }), invalidatesTags: ['Api'], }), + /** Full replace of a CHOICE question's options + correct-answer set (Phase 2). */ + setQuestionOptions: builder.mutation({ + query: ({ id, ...body }) => ({ + url: `/questions/${id}/options`, + method: 'PUT', + body, + }), + invalidatesTags: ['Api'], + }), }), overrideExisting: false, }); @@ -54,9 +70,11 @@ const questionApi = baseApi.injectEndpoints({ export const { useGetQuestionsQuery, useGetQuestionQuery, + useGetQuestionWithOptionsQuery, useCreateQuestionMutation, useUpdateQuestionMutation, useDeleteQuestionMutation, useSubmitQuestionMutation, useReviewQuestionMutation, + useSetQuestionOptionsMutation, } = questionApi; diff --git a/apps/backoffice/src/app/features/question/components/QuestionOptionsEditor.tsx b/apps/backoffice/src/app/features/question/components/QuestionOptionsEditor.tsx new file mode 100644 index 000000000..2daab604a --- /dev/null +++ b/apps/backoffice/src/app/features/question/components/QuestionOptionsEditor.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; +import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react'; +import { BilingualInput, notify, useErrorHandler } from '@ema-platform/ui'; +import type { BilingualValue } from '@ema-platform/ui'; +import { + useGetQuestionWithOptionsQuery, + useSetQuestionOptionsMutation, +} from '../api/question-api'; + +interface DraftOption { + text: BilingualValue; + isCorrect: boolean; +} + +/** + * MCQ options + correct-answer editor for a CHOICE-form question (Phase 2). + * + * Only reachable while editing an already-created question — options attach + * to a question id, matching the backend's `PUT /questions/:id/options` + * full-replace endpoint. Nothing here is ever shown to a candidate; this is + * the authoring side only. + */ +export function QuestionOptionsEditor({ questionId }: { questionId: string }) { + const { t } = useTranslation(); + const { handleError } = useErrorHandler(); + const { data: question, isFetching } = useGetQuestionWithOptionsQuery(questionId); + const [setOptions, { isLoading: isSaving }] = useSetQuestionOptionsMutation(); + const [draft, setDraft] = useState([]); + + useEffect(() => { + if (!question) return; + const existing = question.options ?? []; + setDraft( + existing.length + ? existing + .slice() + .sort((a, b) => a.order - b.order) + .map((o) => ({ text: o.text, isCorrect: false })) + : [ + { text: { en: '', am: '' }, isCorrect: false }, + { text: { en: '', am: '' }, isCorrect: false }, + ], + ); + // isCorrect never comes back from the API by design — an examiner + // re-editing options re-marks the correct one(s) rather than us + // pretending to know what they were. + }, [question]); + + const updateText = (index: number, text: BilingualValue) => { + setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, text } : o))); + }; + + const toggleCorrect = (index: number) => { + setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, isCorrect: !o.isCorrect } : o))); + }; + + const addOption = () => { + setDraft((prev) => [...prev, { text: { en: '', am: '' }, isCorrect: false }]); + }; + + const removeOption = (index: number) => { + setDraft((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleSave = async () => { + if (draft.length < 2) { + notify.error(t('question.options.needAtLeastTwo')); + return; + } + if (!draft.some((o) => o.isCorrect)) { + notify.error(t('question.options.needOneCorrect')); + return; + } + if (draft.some((o) => !o.text.en.trim() || !o.text.am.trim())) { + notify.error(t('question.options.textRequired')); + return; + } + try { + await setOptions({ id: questionId, options: draft }).unwrap(); + notify.success(t('question.options.saved')); + } catch (e) { + handleError(e); + } + }; + + if (isFetching) return ; + + return ( + + } color="blue" variant="light"> + {t('question.options.hint')} + + {draft.map((option, index) => ( + + + updateText(index, v)} + size="sm" + style={{ flex: 1 }} + /> + toggleCorrect(index)} + mb={4} + /> + removeOption(index)} + > + + + + ))} + + + + + {t('question.options.replaceNotice')} + + ); +} diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx index e5026c659..1ee40bc5d 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx @@ -30,6 +30,7 @@ import { useReviewQuestionMutation, } from '../../api/question-api'; import type { Question, QuestionForm } from '../../types/question'; +import { QuestionOptionsEditor } from '../../components/QuestionOptionsEditor'; import { questionColumns } from './columns'; import { questionActionsColumn } from './actions'; @@ -92,6 +93,15 @@ function QuestionForm({ setHours(Number(v))} min={0} size="sm" /> setMinutes(Number(v))} min={0} size="sm" /> + {editing && form === 'CHOICE' && ( + <> + {t('question.options.title')} + + + )} + {!editing && form === 'CHOICE' && ( + {t('question.options.saveFirst')} + )} diff --git a/apps/backoffice/src/app/features/question/types/question.ts b/apps/backoffice/src/app/features/question/types/question.ts index a95bba2b8..b300bc881 100644 --- a/apps/backoffice/src/app/features/question/types/question.ts +++ b/apps/backoffice/src/app/features/question/types/question.ts @@ -16,6 +16,17 @@ export type QuestionStatus = | 'REJECTED' | 'RETIRED'; +/** + * A CHOICE option, as returned by the authoring/QC endpoints. Never carries + * a correctness flag — the API's own answer-key table is never joined into + * this response either, so there's nothing to accidentally serialize here. + */ +export interface QuestionOption { + id: string; + text: LocalePair; + order: number; +} + export interface Question { id: string; certificationId: string; @@ -32,6 +43,8 @@ export interface Question { submittedAt: string | null; createdAt: string; updatedAt: string; + /** Only populated when explicitly requested (`?i=options`). */ + options?: QuestionOption[]; } export interface ReviewQuestionPayload { @@ -64,3 +77,13 @@ export interface UpdateQuestionPayload { points?: number; isActive?: boolean; } + +export interface QuestionOptionInput { + text: LocalePair; + isCorrect: boolean; +} + +export interface SetQuestionOptionsPayload { + id: string; + options: QuestionOptionInput[]; +} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 92400f9ae..63d38a013 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -738,6 +738,20 @@ export const am: Translations = { onlyApprovedUsable: "የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።", error: "ተግባሩ አልተሳካም", }, + options: { + title: "የመልስ አማራጮች", + hint: "ትክክለኛውን አማራጭ ምረጥ/ምረጪ። ማስቀመጥ መላውን የአማራጭ ስብስብ ይተካል።", + optionLabel: "አማራጭ {{number}}", + correct: "ትክክለኛ", + addOption: "አማራጭ ጨምር", + save: "አማራጮችን አስቀምጥ", + saved: "አማራጮች ተቀምጠዋል", + saveFirst: "መጀመሪያ ጥያቄውን አስቀምጥ፣ ከዚያ አማራጮችን ጨምር።", + replaceNotice: "ትክክለኛ መልሶች ከተቀመጡ በኋላ እዚህ አይታዩም — እንደገና ካስተካከልክ/ካስተካከልሽ ዳግም ምረጥ/ምረጪ።", + needAtLeastTwo: "ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልገዋል።", + needOneCorrect: "ቢያንስ አንድ አማራጭ እንደ ትክክለኛ ምረጥ/ምረጪ።", + textRequired: "እያንዳንዱ አማራጭ በሁለቱም ቋንቋዎች ጽሑፍ ያስፈልገዋል።", + }, }, configuration: { diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 338a88e68..49a5a8b12 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -739,6 +739,21 @@ export const en = { 'Only approved items can be placed on an examination paper.', error: 'Operation failed', }, + options: { + title: 'Answer Options', + hint: 'Mark every correct option. Saving replaces the entire option set.', + optionLabel: 'Option {{number}}', + correct: 'Correct', + addOption: 'Add option', + save: 'Save options', + saved: 'Options saved', + saveFirst: 'Save the question first, then add its options.', + replaceNotice: + 'Correct answers are never shown here once saved — re-mark them if you edit this set again.', + needAtLeastTwo: 'A question needs at least two options.', + needOneCorrect: 'Mark at least one option as correct.', + textRequired: 'Every option needs text in both languages.', + }, }, configuration: { From 4edce751ee86d2abea3243956911e6a91f23990c Mon Sep 17 00:00:00 2001 From: mihretue Date: Fri, 14 Aug 2026 09:17:09 +0000 Subject: [PATCH 03/16] feat(exam): candidate exam-taking experience (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New portal feature apps/portal/src/app/features/exam-attempt/, following the repo's existing folder convention (pages//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 --- .../components/ExamCompletion.tsx | 50 +++ .../components/ExamInstructions.tsx | 94 ++++++ .../components/ExamQuestionDisplay.tsx | 118 ++++++++ .../components/ExamQuestionNav.tsx | 57 ++++ .../exam-attempt/components/ExamTimer.tsx | 34 +++ .../exam-attempt/hooks/useExamAttempt.ts | 286 ++++++++++++++++++ .../pages/ExamAttemptPage/index.tsx | 187 ++++++++++++ .../exam-attempt/types/exam-attempt.ts | 78 +++++ .../exams/pages/ExamsPage/columns.tsx | 24 +- .../features/exams/pages/ExamsPage/index.tsx | 3 + apps/portal/src/app/router.tsx | 9 + 11 files changed, 939 insertions(+), 1 deletion(-) create mode 100644 apps/portal/src/app/features/exam-attempt/components/ExamCompletion.tsx create mode 100644 apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx create mode 100644 apps/portal/src/app/features/exam-attempt/components/ExamQuestionDisplay.tsx create mode 100644 apps/portal/src/app/features/exam-attempt/components/ExamQuestionNav.tsx create mode 100644 apps/portal/src/app/features/exam-attempt/components/ExamTimer.tsx create mode 100644 apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts create mode 100644 apps/portal/src/app/features/exam-attempt/pages/ExamAttemptPage/index.tsx create mode 100644 apps/portal/src/app/features/exam-attempt/types/exam-attempt.ts 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)} + + + ))} + + + ) : ( +