From 24fb51de23b78bf3d94932c1111364c20912e784 Mon Sep 17 00:00:00 2001 From: mihretu Date: Mon, 7 Sep 2026 08:12:12 +0000 Subject: [PATCH] fix(exam): reflect authoritative exam state and lock engine-graded results - COC queue status cell shows the server-derived exam state (registered, present, sat, passed, failed) instead of the lagging application status. - Portal examStageFor prefers the server's examState so portal and back office never disagree; NOT_SITTING stage added. - Exam roster shows each candidate's result and lock; Regrade hidden once a result exists. - Record Result modal: only unmarked candidates, empty score boxes (no silent zeros), reason required, backend refusals translated. - Result page: auto-graded marks read-only, per-applicant publish. - Exam page: session window fields, Add Question menu (bank / Excel / scratch), wait metrics panel; PUBLISHED exam status removed. - Portal: exam window shown, early launch and eligibility refusals explained. Co-Authored-By: Claude Fable 5.1 --- .../src/app/features/exam/api/exam-api.ts | 52 +++++ .../ExamCandidatesPanel/columns.tsx | 52 ++++- .../ExamQuestionCreateModal.tsx | 197 ++++++++++++++++++ .../ExamQuestionImportModal.tsx | 174 ++++++++++++++++ .../QuestionBankPickerModal.tsx | 138 ++++++++++++ .../components/ExamQuestionActions/errors.ts | 60 ++++++ .../components/ExamQuestionActions/index.ts | 4 + .../exam/components/ExamWaitMetricsPanel.tsx | 67 ++++++ .../features/exam/pages/ExamDetailPage.tsx | 71 ++++++- .../features/exam/pages/ExamPage/columns.tsx | 14 +- .../features/exam/pages/ExamPage/index.tsx | 53 ++++- .../exam/pages/ExamPage/validation.spec.ts | 32 +++ .../exam/pages/ExamPage/validation.ts | 9 + .../src/app/features/exam/types/exam.ts | 125 ++++++++++- .../pages/LicenseQueuePage/columns.tsx | 30 +++ .../src/app/features/result/api/result-api.ts | 11 + .../components/RecordResultModal/columns.tsx | 17 +- .../components/RecordResultModal/index.tsx | 101 ++++++--- .../result/pages/ResultPage/actions.tsx | 51 +++-- .../result/pages/ResultPage/index.tsx | 59 +++++- .../src/app/features/result/types/result.ts | 7 + apps/backoffice/src/app/i18n/locales/am.ts | 133 +++++++++++- apps/backoffice/src/app/i18n/locales/en.ts | 133 +++++++++++- .../certificates/pages/CertificatesPage.tsx | 1 + .../components/ExamInstructions.tsx | 25 ++- .../exam-attempt/hooks/useExamAttempt.ts | 15 +- .../exam-attempt/types/exam-attempt.ts | 3 + .../app/features/exams/exam-window.spec.ts | 39 ++++ .../src/app/features/exams/exam-window.ts | 84 ++++++++ .../exams/pages/ExamsPage/columns.tsx | 37 +++- .../features/exams/pages/ExamsPage/index.tsx | 66 +++++- .../app/features/licensing/exam-stage.spec.ts | 64 ++++++ .../src/app/features/licensing/exam-stage.ts | 30 +++ apps/portal/src/app/i18n/locales/am.ts | 12 +- apps/portal/src/app/i18n/locales/en.ts | 14 +- .../lib/features/licensing/licensing.types.ts | 7 + 36 files changed, 1888 insertions(+), 99 deletions(-) create mode 100644 apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionCreateModal.tsx create mode 100644 apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionImportModal.tsx create mode 100644 apps/backoffice/src/app/features/exam/components/ExamQuestionActions/QuestionBankPickerModal.tsx create mode 100644 apps/backoffice/src/app/features/exam/components/ExamQuestionActions/errors.ts create mode 100644 apps/backoffice/src/app/features/exam/components/ExamQuestionActions/index.ts create mode 100644 apps/backoffice/src/app/features/exam/components/ExamWaitMetricsPanel.tsx create mode 100644 apps/portal/src/app/features/exams/exam-window.spec.ts create mode 100644 apps/portal/src/app/features/exams/exam-window.ts diff --git a/apps/backoffice/src/app/features/exam/api/exam-api.ts b/apps/backoffice/src/app/features/exam/api/exam-api.ts index 4dbe8f713..5543c5c7d 100644 --- a/apps/backoffice/src/app/features/exam/api/exam-api.ts +++ b/apps/backoffice/src/app/features/exam/api/exam-api.ts @@ -13,7 +13,13 @@ import type { ResolveIncidentPayload, RegradeOutcome, GradingSheet, + AddExamQuestionsPayload, + CreateExamQuestionPayload, + ImportExamQuestionsPayload, + QuestionImportReport, + ExamWaitMetrics, } from '../types/exam'; +import type { Question } from '../../question/types/question'; const examApi = baseApi.injectEndpoints({ endpoints: (builder) => ({ @@ -60,6 +66,48 @@ const examApi = baseApi.injectEndpoints({ }), invalidatesTags: ['Api'], }), + // --- Question management from the exam page --------------------------- + /** Append approved bank items to the paper, keeping what is already on it. */ + addExamQuestions: builder.mutation({ + query: ({ examId, questionIds }) => ({ + url: `/exams/${examId}/questions/add`, + method: 'POST', + body: { questionIds }, + }), + invalidatesTags: ['Api'], + }), + /** Author a question under the exam's subject and put it on the paper in one call. */ + createExamQuestion: builder.mutation({ + query: ({ examId, ...body }) => ({ + url: `/exams/${examId}/questions/new`, + method: 'POST', + body, + }), + invalidatesTags: ['Api'], + }), + /** + * Excel import. `dryRun` validates and previews without writing; the real + * import runs the same validation and is all-or-nothing on the server. + */ + importExamQuestions: builder.mutation({ + query: ({ examId, file, dryRun }) => { + const body = new FormData(); + body.append('file', file); + // No Content-Type header: fetch sets it with the multipart boundary. + return { + url: `/exams/${examId}/questions/import?dryRun=${dryRun ? 'true' : 'false'}`, + method: 'POST', + body, + }; + }, + // A dry run changes nothing, so the paper does not need refetching. + invalidatesTags: (_result, error, { dryRun }) => (error || dryRun ? [] : ['Api']), + }), + /** Read-only analytics over the session's own timestamps. */ + getExamWaitMetrics: builder.query({ + query: (examId) => `/exams/${examId}/wait-metrics`, + providesTags: ['Api'], + }), // --- Candidates and attendance (US-EXAM-007/009) --------------------- getExamRegistrations: builder.query({ query: (examId) => `/exams/${examId}/registrations`, @@ -123,6 +171,10 @@ export const { useDeleteExamMutation, useAssignQuestionsMutation, useSelectRandomQuestionsMutation, + useAddExamQuestionsMutation, + useCreateExamQuestionMutation, + useImportExamQuestionsMutation, + useGetExamWaitMetricsQuery, useGetExamRegistrationsQuery, useRecordAttendanceMutation, useGetExamIncidentsQuery, diff --git a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx index 0884a3db6..1b7aa125c 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx @@ -1,5 +1,5 @@ -import { ActionIcon, Badge, Menu, Text } from '@mantine/core'; -import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react'; +import { ActionIcon, Badge, Group, Menu, Text, Tooltip } from '@mantine/core'; +import { IconDotsVertical, IconLock, IconRefresh, IconUserCheck } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; @@ -78,13 +78,59 @@ export function examCandidateColumns( ), }, + { + // What the paper came to, if it has been marked — so the invigilator + // and the marking officer see at a glance whose result already exists + // (and whether the engine produced it, in which case it is locked). + header: t('exam.candidates.result'), + cell: ({ row }) => { + const result = row.original.result; + if (!result) { + return ( + + {t('exam.candidates.noResult')} + + ); + } + const outcome = t(`exam.candidates.outcome.${result.status}`); + const review = t(`result.review.${result.reviewStatus}`, result.reviewStatus); + return ( + + + : undefined} + > + {outcome} · {result.totalScore} + + + {review} + + + + ); + }, + }, { header: '', label: t('exam.candidates.record'), align: 'right', cell: ({ row }) => { const attemptStatus = row.original.attempt?.status; - const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED'; + // Regrading creates a result; once one exists the API refuses + // (result_already_recorded), so the action is not offered. + const canRegrade = + (attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED') && + !row.original.result; return ( diff --git a/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionCreateModal.tsx b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionCreateModal.tsx new file mode 100644 index 000000000..bf06295a7 --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionCreateModal.tsx @@ -0,0 +1,197 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActionIcon, + Button, + Checkbox, + Group, + Modal, + NumberInput, + Select, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; +import { ModalFooter, notify } from '@ema-platform/ui'; +import { extractErrorMessage } from '@ema-platform/api'; +import { useCreateExamQuestionMutation } from '../../api/exam-api'; +import type { Exam, QuestionForm } from '../../types/exam'; +import { describeExamQuestionError } from './errors'; + +type DraftOption = { textEn: string; textAm: string; isCorrect: boolean }; + +const BLANK: DraftOption[] = [ + { textEn: '', textAm: '', isCorrect: false }, + { textEn: '', textAm: '', isCorrect: false }, +]; + +/** + * "Add new question from scratch": authored under this exam's subject and + * put on its paper in one call — the officer never leaves the exam or copies + * an id. The item is a real bank question (options, answer key), reusable on + * a later paper. + */ +export function ExamQuestionCreateModal({ + exam, + opened, + onClose, +}: { + exam: Exam; + opened: boolean; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [createQuestion, { isLoading }] = useCreateExamQuestionMutation(); + const [titleEn, setTitleEn] = useState(''); + const [titleAm, setTitleAm] = useState(''); + const [form, setForm] = useState(exam.form === 'ESSAY' ? 'ESSAY' : 'CHOICE'); + const [points, setPoints] = useState(1); + const [options, setOptions] = useState(BLANK); + + const reset = () => { + setTitleEn(''); + setTitleAm(''); + setForm(exam.form === 'ESSAY' ? 'ESSAY' : 'CHOICE'); + setPoints(1); + setOptions(BLANK); + }; + const close = () => { + reset(); + onClose(); + }; + + const updateOption = (index: number, patch: Partial) => + setOptions((current) => current.map((o, i) => (i === index ? { ...o, ...patch } : o))); + + // A mixed (BOTH) paper takes either form; otherwise the question must match. + const formOptions = (exam.form === 'BOTH' ? ['ESSAY', 'CHOICE'] : [exam.form]).map((value) => ({ + value, + label: t(`exam.formType.${value}`), + })); + + const submit = async () => { + if (!titleEn.trim() || !form || !(points > 0)) { + notify.error(t('exam.newQuestion.fillRequired')); + return; + } + if (form === 'CHOICE') { + if (options.length < 2) return void notify.error(t('exam.newQuestion.needTwo')); + if (!options.some((o) => o.isCorrect)) return void notify.error(t('exam.newQuestion.needCorrect')); + if (options.some((o) => !o.textEn.trim())) return void notify.error(t('exam.newQuestion.textRequired')); + } + try { + await createQuestion({ + examId: exam.id, + // The API requires Amharic; it falls back to the English text server-side + // as well, but sending it explicitly keeps the request self-describing. + title: { en: titleEn.trim(), am: titleAm.trim() || titleEn.trim() }, + form, + points, + options: + form === 'CHOICE' + ? options.map((o) => ({ + text: { en: o.textEn.trim(), am: o.textAm.trim() || o.textEn.trim() }, + isCorrect: o.isCorrect, + })) + : undefined, + }).unwrap(); + notify.success(t('exam.newQuestion.created')); + close(); + } catch (error) { + notify.error(describeExamQuestionError(t, extractErrorMessage(error, t('exam.error')))); + } + }; + + return ( + + + {t('exam.newQuestion.hint')} + setTitleEn(e.currentTarget.value)} + size="sm" + required + /> + setTitleAm(e.currentTarget.value)} + size="sm" + /> + +