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 d313a38ac..4dbe8f713 100644
--- a/apps/backoffice/src/app/features/exam/api/exam-api.ts
+++ b/apps/backoffice/src/app/features/exam/api/exam-api.ts
@@ -12,6 +12,7 @@ import type {
CreateIncidentPayload,
ResolveIncidentPayload,
RegradeOutcome,
+ GradingSheet,
} from '../types/exam';
const examApi = baseApi.injectEndpoints({
@@ -101,6 +102,15 @@ const examApi = baseApi.injectEndpoints({
}),
invalidatesTags: ['Api'],
}),
+ /** The candidate's answers plus auto-computable CHOICE scores, for RecordResultModal. */
+ getGradingSheet: builder.query<
+ GradingSheet,
+ { examId: string; profileId: string }
+ >({
+ query: ({ examId, profileId }) =>
+ `/exam-attempts/exam/${examId}/candidate/${profileId}/grading-sheet`,
+ providesTags: ['Api'],
+ }),
}),
overrideExisting: false,
});
@@ -119,4 +129,5 @@ export const {
useRecordIncidentMutation,
useResolveIncidentMutation,
useRegradeAttemptMutation,
+ useGetGradingSheetQuery,
} = examApi;
diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
index a17673197..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 ?? [];
@@ -122,10 +130,14 @@ export function ExamDetailPage() {
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
// must not offer drafts or retired questions either.
//
- // BOTH describes a mixed paper — a question itself is never "BOTH" (see
+ // Filters on exam.form alone, not administrationMethod: the backend no
+ // longer restricts ONLINE to CHOICE (ExamService no longer has an
+ // assertOnlineIsChoiceOnly gate), so exam.form is now the sole source of
+ // truth for what belongs on the paper, ONLINE or OFFLINE alike. BOTH
+ // describes a mixed paper — a question itself is never "BOTH" (see
// QuestionForm), so an equality check against it would match nothing and
- // silently offer zero questions. Same skip-condition as the backend's own
- // random draw (ExamService.selectRandomQuestions).
+ // silently offer zero questions; skipped the same way the backend's own
+ // random draw does (ExamService.selectRandomQuestions).
const eligibleQuestions = useMemo(() => {
if (!exam) return [];
return allQuestions
@@ -179,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', {
@@ -200,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', {
@@ -452,19 +468,45 @@ export function ExamDetailPage() {
{t("exam.detail.questionsSection", { pts: totalPoints })}
- }
- onClick={openAssignModal}
- >
- {t("exam.manageQuestions")}
-
+
+ {paperLocked && (
+
+ {t("exam.paperLocked")}
+
+ )}
+
+ {/* Wrapped: a disabled Mantine Button fires no pointer events,
+ so the tooltip needs an enabled element to hang off. */}
+
+ }
+ onClick={openAssignModal}
+ disabled={paperLocked}
+ >
+ {t("exam.manageQuestions")}
+
+
+
+
{(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/features/exam/pages/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx
index 874526c70..c4578d308 100644
--- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx
+++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx
@@ -79,21 +79,44 @@ function ExamForm({
const [status, setStatus] = useState(editing?.status ?? null);
const [activeTab, setActiveTab] = useState("basic");
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
+ /**
+ * Split per tab so "Next" can check just the tab in front of the user.
+ * Submitting from Basic Info used to complain about Settings fields the
+ * user had not been shown yet — the error was correct and unactionable at
+ * the same time. Each returns the message key for what is missing, or null.
+ */
+ const validateBasic = (): string | null => {
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
- setActiveTab("basic");
- notify.error(t("exam.form.fillRequiredBasic"));
- return;
+ return "exam.form.fillRequiredBasic";
}
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
- setActiveTab("basic");
- notify.error(t("exam.form.directionBothLanguages"));
+ return "exam.form.directionBothLanguages";
+ }
+ return null;
+ };
+
+ const validateSettings = (): string | null =>
+ !type || !form || !adminMethod || !evalMethod || !cuttingPoint
+ ? "exam.form.fillRequiredSettings"
+ : null;
+
+ const goNext = () => {
+ const error = validateBasic();
+ if (error) {
+ notify.error(t(error));
return;
}
- if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
- setActiveTab("settings");
- notify.error(t("exam.form.fillRequiredSettings"));
+ setActiveTab("settings");
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ // Still checks both: the tabs are clickable, so a user can reach Settings
+ // without going through Next.
+ const error = validateBasic() ?? validateSettings();
+ if (error) {
+ setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic");
+ notify.error(t(error));
return;
}
onSubmit(
@@ -255,12 +278,6 @@ function ExamForm({
onChange={setForm}
size="sm"
required
- disabled={adminMethod === "ONLINE"}
- description={
- adminMethod === "ONLINE"
- ? t("exam.form.onlineChoiceOnlyHint")
- : undefined
- }
/>