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 83dbd473b..78f3c74ea 100644 --- a/apps/backoffice/src/app/features/exam/api/exam-api.ts +++ b/apps/backoffice/src/app/features/exam/api/exam-api.ts @@ -20,7 +20,9 @@ const examApi = baseApi.injectEndpoints({ providesTags: ['Api'], }), getExam: builder.query({ - query: (id) => `/exams/${id}?i=questions`, + // Nested relation so CHOICE questions carry their options here too — + // needed to print real answer choices instead of blank A/B/C/D lines. + query: (id) => `/exams/${id}?i=questions,questions.options`, providesTags: ['Api'], }), createExam: builder.mutation({ diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index e49efe907..0bbb98aa8 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -174,7 +174,12 @@ export function ExamDetailPage() { notify.error( key.startsWith('insufficient_approved_questions') ? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})` - : key, + : key.startsWith('paper_cannot_reach_cutting_point') + ? t('exam.cannotReachCuttingPoint', { + max: key.split(':')[1]?.split('/')[0] ?? '', + cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '', + }) + : key, ); } }; @@ -190,18 +195,35 @@ export function ExamDetailPage() { notify.error( key.startsWith('question_not_approved') ? t('question.qc.onlyApprovedUsable') - : key, + : key.startsWith('paper_cannot_reach_cutting_point') + ? t('exam.cannotReachCuttingPoint', { + max: key.split(':')[1]?.split('/')[0] ?? '', + cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '', + }) + : key, ); } }; const handlePrint = async () => { - const total = (exam.questions ?? []).reduce( - (s, q) => s + Number(q.points), - 0, - ); - if (total < Number(exam.cuttingPoint)) { + // The reachable max depends on the evaluation method, not the raw point + // sum — mirrors RecordResultModal's grading math so "can this paper pass" + // means the same thing here as it does at marking time. Cutting point can + // be raised after the paper was assembled (edit modal, no re-check on + // save), so this still needs to run even though assignment now enforces + // it too. + const questions = exam.questions ?? []; + const total = questions.reduce((s, q) => s + Number(q.points), 0); + const reachableMax = + exam.evaluationMethod === 'AVERAGE' + ? questions.length + ? total / questions.length + : 0 + : exam.evaluationMethod === 'PERCENTAGE' + ? 100 + : total; + if (reachableMax < Number(exam.cuttingPoint)) { notify.error( - `Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`, + `This paper cannot reach the passing mark under its ${EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} evaluation (max ${reachableMax}, pass mark ${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`, ); return; } @@ -235,7 +257,23 @@ export function ExamDetailPage() {

${titleStr}

${descStr ? `

${descStr}

` : ""} ${q.form === "ESSAY" ? '
'.repeat(3) : ""} - ${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `

${l}

`).join("") : ""} + ${ + q.form === "CHOICE" + ? q.options && q.options.length + ? q.options + .slice() + .sort((a, b) => a.order - b.order) + .map( + (o, oi) => + `

${String.fromCharCode(65 + oi)}. ${o.text[locale] || o.text.en}

`, + ) + .join("") + // No options on record (legacy question, or options relation + // wasn't loaded) — fall back to blank lines rather than + // printing nothing. + : ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `

${l}

`).join("") + : "" + } `; }) .join(""); diff --git a/apps/backoffice/src/app/features/exam/types/exam.ts b/apps/backoffice/src/app/features/exam/types/exam.ts index d1a3d3587..cb42676fe 100644 --- a/apps/backoffice/src/app/features/exam/types/exam.ts +++ b/apps/backoffice/src/app/features/exam/types/exam.ts @@ -15,11 +15,19 @@ export type ExamStatus = | "POSTPONED" | "PUBLISHED"; +/** Only populated when the exam is fetched with `?i=questions,questions.options`. */ +export interface QuestionOptionBrief { + id: string; + text: LocalePair; + order: number; +} + export interface QuestionBrief { id: string; title: LocalePair; form: QuestionForm; points: number; + options?: QuestionOptionBrief[]; } export interface Exam { diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index ec586b2bd..5590d57a4 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -368,6 +368,8 @@ export const am: Translations = { randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል", randomError: "ጥያቄዎችን መምረጥ አልተቻለም", notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።", + cannotReachCuttingPoint: + "ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።", }, country: { diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 5f4b5f902..710924585 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -366,6 +366,8 @@ export const en = { randomError: 'Could not draw questions', notEnoughApproved: '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.', }, country: {