mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
fix(exam): guard question assignment once the paper is locked
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.
This commit is contained in:
@@ -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 })}
|
||||
</Title>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
{paperLocked && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("exam.paperLocked")}
|
||||
</Badge>
|
||||
)}
|
||||
<Tooltip
|
||||
label={t("exam.paperLockedHint", {
|
||||
count: registrations?.length ?? 0,
|
||||
})}
|
||||
disabled={!paperLocked}
|
||||
multiline
|
||||
w={280}
|
||||
>
|
||||
{/* Wrapped: a disabled Mantine Button fires no pointer events,
|
||||
so the tooltip needs an enabled element to hang off. */}
|
||||
<Box>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
disabled={paperLocked}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
{(exam.questions ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t("exam.noQuestionsAssigned")}
|
||||
<Alert
|
||||
color={paperLocked ? "red" : "gray"}
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
{paperLocked
|
||||
? t("exam.paperLockedHint", { count: registrations?.length ?? 0 })
|
||||
: t("exam.noQuestionsAssigned")}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -387,6 +387,9 @@ export const am: Translations = {
|
||||
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
|
||||
cannotReachCuttingPoint:
|
||||
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
|
||||
paperLocked: "ወረቀቱ ተቆልፏል",
|
||||
paperLockedHint:
|
||||
"ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።",
|
||||
},
|
||||
|
||||
country: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user