From b49de1c1a8580e7a9b89b56355ec375689576bad Mon Sep 17 00:00:00 2001 From: mihretue Date: Mon, 24 Aug 2026 14:04:24 +0000 Subject: [PATCH] fix(exam): guard question assignment once the paper is locked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail page fired assign/draw requests blind and surfaced the raw backend key to the user — a locked paper showed up as the literal string "paper_locked_after_registration" in a toast. Manage Questions is now disabled once any candidate has registered, with a tooltip and badge explaining why; both the manual assign and the random draw live behind that modal, so one guard covers the same ground the backend's assertPaperEditable does. The empty-paper alert now explains the deadlock instead of telling the user to click a button that will fail, and both error handlers translate paper_locked_after_registration in case someone registers while the modal is open. Registrations come from the query ExamCandidatesPanel already runs on this page, so RTK Query serves it from cache rather than issuing a second request. --- .../features/exam/pages/ExamDetailPage.tsx | 62 +++++++++++++++---- apps/backoffice/src/app/i18n/locales/am.ts | 3 + apps/backoffice/src/app/i18n/locales/en.ts | 3 + 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index 4abe06117..ff8759458 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -22,6 +22,7 @@ import { TextInput, ThemeIcon, Box, + Tooltip, rem, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; @@ -48,6 +49,7 @@ import { useUpdateExamMutation, useAssignQuestionsMutation, useSelectRandomQuestionsMutation, + useGetExamRegistrationsQuery, } from '../api/exam-api'; import { useGetQuestionsQuery } from '../../question/api/question-api'; import { useGetCertificationsQuery } from '../../certification/api/certification-api'; @@ -114,6 +116,12 @@ export function ExamDetailPage() { const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation(); const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id }); + // The backend locks the paper the moment the first candidate registers + // (ExamService.assertPaperEditable) — every candidate must sit the same + // paper. Same query ExamCandidatesPanel already runs, so RTK Query serves + // it from cache rather than issuing a second request. + const { data: registrations } = useGetExamRegistrationsQuery(id ?? '', { skip: !id }); + const paperLocked = (registrations?.length ?? 0) > 0; const { data: qRes } = useGetQuestionsQuery(); const { data: certRes } = useGetCertificationsQuery(); const allQuestions = qRes?.items ?? []; @@ -183,7 +191,9 @@ export function ExamDetailPage() { } catch (error) { const key = extractErrorMessage(error, t('exam.randomError')); notify.error( - key.startsWith('insufficient_approved_questions') + key === 'paper_locked_after_registration' + ? t('exam.paperLockedHint', { count: registrations?.length ?? 0 }) + : key.startsWith('insufficient_approved_questions') ? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})` : key.startsWith('paper_cannot_reach_cutting_point') ? t('exam.cannotReachCuttingPoint', { @@ -204,7 +214,9 @@ export function ExamDetailPage() { } catch (error) { const key = extractErrorMessage(error, 'Failed to assign questions'); notify.error( - key.startsWith('question_not_approved') + key === 'paper_locked_after_registration' + ? t('exam.paperLockedHint', { count: registrations?.length ?? 0 }) + : key.startsWith('question_not_approved') ? t('question.qc.onlyApprovedUsable') : key.startsWith('paper_cannot_reach_cutting_point') ? t('exam.cannotReachCuttingPoint', { @@ -456,19 +468,45 @@ export function ExamDetailPage() { {t("exam.detail.questionsSection", { pts: totalPoints })} - + + {paperLocked && ( + + {t("exam.paperLocked")} + + )} + + {/* Wrapped: a disabled Mantine Button fires no pointer events, + so the tooltip needs an enabled element to hang off. */} + + + + + {(exam.questions ?? []).length === 0 ? ( - }> - {t("exam.noQuestionsAssigned")} + } + > + {paperLocked + ? t("exam.paperLockedHint", { count: registrations?.length ?? 0 }) + : t("exam.noQuestionsAssigned")} ) : ( diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 292a374ae..c8e51193d 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -387,6 +387,9 @@ export const am: Translations = { notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።", cannotReachCuttingPoint: "ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።", + paperLocked: "ወረቀቱ ተቆልፏል", + paperLockedHint: + "ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።", }, country: { diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 8c0ff66ff..7152067ce 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -386,6 +386,9 @@ export const en = { 'Not enough approved questions in the bank for this subject.', cannotReachCuttingPoint: 'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.', + paperLocked: 'Paper locked', + paperLockedHint: + '{{count}} candidate(s) have registered for this session. Every candidate must sit the same paper, so questions can no longer be changed.', }, country: {