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 })} - + + {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/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 - } />