fix(exam): print real MCQ options, evaluation-method-aware cutting point check

Print showed blank A/B/C/D fill-in lines for every CHOICE question instead
of the real options — the detail page never fetched option data at all
(GET /exams/:id?i=questions, no nested options), and the print template
hardcoded the blanks regardless. Now requests
?i=questions,questions.options and prints the real option text, falling
back to blank lines only when a question genuinely has none on record.

Also fixed the print-time cutting-point guard: it compared raw point sum
to cutting point regardless of evaluationMethod, wrongly blocking valid
low-point-sum PERCENTAGE papers from printing. Now branches the same way
RecordResultModal's grading preview already does, and surfaces the
matching backend validation error (paper_cannot_reach_cutting_point) added
server-side for the same reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mihretue
2026-08-17 09:43:09 +00:00
parent 4f692c6bcf
commit 4a7d36369f
5 changed files with 62 additions and 10 deletions

View File

@@ -20,7 +20,9 @@ const examApi = baseApi.injectEndpoints({
providesTags: ['Api'],
}),
getExam: builder.query<Exam, string>({
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<Exam, CreateExamPayload>({

View File

@@ -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() {
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ""}
${q.form === "ESSAY" ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ""}
${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("") : ""}
${
q.form === "CHOICE"
? q.options && q.options.length
? q.options
.slice()
.sort((a, b) => a.order - b.order)
.map(
(o, oi) =>
`<p style="margin: 4px 0; font-size: 13px;">${String.fromCharCode(65 + oi)}. ${o.text[locale] || o.text.en}</p>`,
)
.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) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("")
: ""
}
</div>`;
})
.join("");

View File

@@ -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 {

View File

@@ -365,6 +365,8 @@ export const am: Translations = {
randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል",
randomError: "ጥያቄዎችን መምረጥ አልተቻለም",
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
cannotReachCuttingPoint:
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
},
country: {

View File

@@ -362,6 +362,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: {