From 34208ed0d663bc3be6895ef4f61184ed591f660e Mon Sep 17 00:00:00 2001 From: mihretue Date: Mon, 24 Aug 2026 13:02:08 +0000 Subject: [PATCH] feat(exam): show candidate answers + prefill auto-scores in RecordResultModal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires useGetGradingSheetQuery into the grading modal: a new 'Candidate's Answer' column shows the selected option or essay text per question, and whichever CHOICE scores auto-grading could already compute get prefilled into the score inputs (tagged 'Auto-graded', still editable — the examiner can override). Grading a mixed or ESSAY paper no longer means guessing at what was answered or re-deriving the CHOICE portion by eye. Degrades to blank inputs exactly as before for an OFFLINE candidate or one who hasn't sat an online attempt (gradingSheet returns empty answers for those, not an error). --- .../components/RecordResultModal/columns.tsx | 51 ++++++++++++++----- .../components/RecordResultModal/index.tsx | 32 +++++++++++- apps/backoffice/src/app/i18n/locales/am.ts | 3 ++ apps/backoffice/src/app/i18n/locales/en.ts | 3 ++ 4 files changed, 75 insertions(+), 14 deletions(-) diff --git a/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx index f224aa828..9503b1120 100644 --- a/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx +++ b/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx @@ -1,7 +1,7 @@ -import { NumberInput, Text, TextInput } from '@mantine/core'; +import { Badge, Group, NumberInput, Text, TextInput } from '@mantine/core'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; -import type { QuestionBrief } from '../../../exam/types/exam'; +import type { GradingSheetQuestion, QuestionBrief } from '../../../exam/types/exam'; export function recordResultColumns( t: TFunction, @@ -11,6 +11,9 @@ export function recordResultColumns( questionRemarks: Record; onScoreChange: (questionId: string, value: number) => void; onRemarkChange: (questionId: string, value: string) => void; + /** The candidate's own answer + auto-score, when available (empty for + * an OFFLINE candidate or one who hasn't sat an online attempt). */ + answersByQuestion: Map; }, ): AdvancedColumn[] { return [ @@ -22,6 +25,20 @@ export function recordResultColumns( ), }, + { + header: t('result.recordModal.candidateAnswer'), + cell: ({ row }) => { + const answer = handlers.answersByQuestion.get(row.original.id); + if (!answer || (!answer.answerText && !answer.selectedOptionText)) { + return {t('result.recordModal.noAnswer')}; + } + return ( + + {answer.selectedOptionText?.[locale] ?? answer.answerText} + + ); + }, + }, { header: t('result.recordModal.maxPoints'), cell: ({ row }) => ( @@ -32,16 +49,26 @@ export function recordResultColumns( }, { header: t('result.recordModal.score'), - cell: ({ row }) => ( - handlers.onScoreChange(row.original.id, Number(v))} - min={0} - max={row.original.points} - size="xs" - style={{ width: 80 }} - /> - ), + cell: ({ row }) => { + const autoGraded = handlers.answersByQuestion.get(row.original.id)?.autoScore != null; + return ( + + handlers.onScoreChange(row.original.id, Number(v))} + min={0} + max={row.original.points} + size="xs" + style={{ width: 80 }} + /> + {autoGraded && ( + + {t('result.recordModal.autoGraded')} + + )} + + ); + }, }, { header: t('result.recordModal.remark'), diff --git a/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx index 15ed50335..b073b1ca1 100644 --- a/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx +++ b/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal, @@ -20,7 +20,7 @@ import { extractErrorMessage } from '@ema-platform/api'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { recordResultColumns } from './columns'; import { useCreateResultMutation } from '../../api/result-api'; -import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api'; +import { useGetExamRegistrationsQuery, useGetGradingSheetQuery } from '../../../exam/api/exam-api'; import type { Exam } from '../../../exam/types/exam'; function InfoRow({ label, value }: { label: string; value: string }) { @@ -55,12 +55,39 @@ export function RecordResultModal({ const { data: registrations } = useGetExamRegistrationsQuery(exam.id, { skip: !opened, }); + // The candidate's own answers plus whatever score auto-grading could + // already compute for the CHOICE portion — degrades to "no data" for an + // OFFLINE candidate or one who never sat an online attempt, same as + // before this existed. + const { data: gradingSheet } = useGetGradingSheetQuery( + { examId: exam.id, profileId: selectedSeafarerId ?? '' }, + { skip: !opened || !selectedSeafarerId }, + ); + const answersByQuestion = new Map( + (gradingSheet?.questions ?? []).map((q) => [q.questionId, q]), + ); const [createResult, { isLoading: isSaving }] = useCreateResultMutation(); const table = useServerTable(); const questions = exam.questions ?? []; const pagedQuestions = table.paginate(questions); + // Prefill (never override) the CHOICE questions auto-grading already + // scored — the examiner only has to key in the ESSAY marks. A fresh + // seafarer selection always starts from an empty scores map, so this + // only ever fills in blanks, never stomps a manual edit already made. + useEffect(() => { + if (!gradingSheet) return; + const autoScores: Record = {}; + for (const q of gradingSheet.questions) { + if (q.autoScore !== null) autoScores[q.questionId] = q.autoScore; + } + if (Object.keys(autoScores).length) { + setScores((prev) => ({ ...autoScores, ...prev })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gradingSheet]); + const seafarerOptions = (registrations ?? []) .filter((registration) => ['PRESENT', 'LATE'].includes(registration.attendanceStatus), @@ -175,6 +202,7 @@ export function RecordResultModal({ questionRemarks, onScoreChange: handleScoreChange, onRemarkChange: handleQuestionRemarkChange, + answersByQuestion, })} data={pagedQuestions.rows} itemCount={pagedQuestions.itemCount} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 1e75f20eb..292a374ae 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -657,6 +657,9 @@ export const am: Translations = { seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ", scorePerQuestion: "በጥያቄ ውጤት", question: "ጥያቄ", + candidateAnswer: "የተፈታኙ መልስ", + noAnswer: "የተመዘገበ መልስ የለም", + autoGraded: "በራስ-ሰር የተመዘነ", maxPoints: "ከፍተኛ ውጤት", score: "ውጤት", remark: "ማስታወሻ", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 3af7597f0..8c0ff66ff 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -657,6 +657,9 @@ export const en = { seafarerPlaceholder: 'Search and select a seafarer', scorePerQuestion: 'Score per Question', question: 'Question', + candidateAnswer: "Candidate's Answer", + noAnswer: 'No answer on file', + autoGraded: 'Auto-graded', maxPoints: 'Max Points', score: 'Score', remark: 'Remark',