feat(exam): show candidate answers + prefill auto-scores in RecordResultModal

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).
This commit is contained in:
mihretue
2026-08-24 13:02:08 +00:00
parent e0ed10b823
commit 34208ed0d6
4 changed files with 75 additions and 14 deletions

View File

@@ -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 { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; 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( export function recordResultColumns(
t: TFunction, t: TFunction,
@@ -11,6 +11,9 @@ export function recordResultColumns(
questionRemarks: Record<string, string>; questionRemarks: Record<string, string>;
onScoreChange: (questionId: string, value: number) => void; onScoreChange: (questionId: string, value: number) => void;
onRemarkChange: (questionId: string, value: string) => 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<string, GradingSheetQuestion>;
}, },
): AdvancedColumn<QuestionBrief>[] { ): AdvancedColumn<QuestionBrief>[] {
return [ return [
@@ -22,6 +25,20 @@ export function recordResultColumns(
</Text> </Text>
), ),
}, },
{
header: t('result.recordModal.candidateAnswer'),
cell: ({ row }) => {
const answer = handlers.answersByQuestion.get(row.original.id);
if (!answer || (!answer.answerText && !answer.selectedOptionText)) {
return <Text fz="xs" c="dimmed">{t('result.recordModal.noAnswer')}</Text>;
}
return (
<Text fz="sm" maw={220} lineClamp={3}>
{answer.selectedOptionText?.[locale] ?? answer.answerText}
</Text>
);
},
},
{ {
header: t('result.recordModal.maxPoints'), header: t('result.recordModal.maxPoints'),
cell: ({ row }) => ( cell: ({ row }) => (
@@ -32,16 +49,26 @@ export function recordResultColumns(
}, },
{ {
header: t('result.recordModal.score'), header: t('result.recordModal.score'),
cell: ({ row }) => ( cell: ({ row }) => {
<NumberInput const autoGraded = handlers.answersByQuestion.get(row.original.id)?.autoScore != null;
value={handlers.scores[row.original.id] ?? 0} return (
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))} <Group gap={4} wrap="nowrap">
min={0} <NumberInput
max={row.original.points} value={handlers.scores[row.original.id] ?? 0}
size="xs" onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
style={{ width: 80 }} min={0}
/> max={row.original.points}
), size="xs"
style={{ width: 80 }}
/>
{autoGraded && (
<Badge size="xs" variant="light" color="teal">
{t('result.recordModal.autoGraded')}
</Badge>
)}
</Group>
);
},
}, },
{ {
header: t('result.recordModal.remark'), header: t('result.recordModal.remark'),

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Modal, Modal,
@@ -20,7 +20,7 @@ import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { recordResultColumns } from './columns'; import { recordResultColumns } from './columns';
import { useCreateResultMutation } from '../../api/result-api'; 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'; import type { Exam } from '../../../exam/types/exam';
function InfoRow({ label, value }: { label: string; value: string }) { function InfoRow({ label, value }: { label: string; value: string }) {
@@ -55,12 +55,39 @@ export function RecordResultModal({
const { data: registrations } = useGetExamRegistrationsQuery(exam.id, { const { data: registrations } = useGetExamRegistrationsQuery(exam.id, {
skip: !opened, 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 [createResult, { isLoading: isSaving }] = useCreateResultMutation();
const table = useServerTable(); const table = useServerTable();
const questions = exam.questions ?? []; const questions = exam.questions ?? [];
const pagedQuestions = table.paginate(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<string, number> = {};
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 ?? []) const seafarerOptions = (registrations ?? [])
.filter((registration) => .filter((registration) =>
['PRESENT', 'LATE'].includes(registration.attendanceStatus), ['PRESENT', 'LATE'].includes(registration.attendanceStatus),
@@ -175,6 +202,7 @@ export function RecordResultModal({
questionRemarks, questionRemarks,
onScoreChange: handleScoreChange, onScoreChange: handleScoreChange,
onRemarkChange: handleQuestionRemarkChange, onRemarkChange: handleQuestionRemarkChange,
answersByQuestion,
})} })}
data={pagedQuestions.rows} data={pagedQuestions.rows}
itemCount={pagedQuestions.itemCount} itemCount={pagedQuestions.itemCount}

View File

@@ -657,6 +657,9 @@ export const am: Translations = {
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ", seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
scorePerQuestion: "በጥያቄ ውጤት", scorePerQuestion: "በጥያቄ ውጤት",
question: "ጥያቄ", question: "ጥያቄ",
candidateAnswer: "የተፈታኙ መልስ",
noAnswer: "የተመዘገበ መልስ የለም",
autoGraded: "በራስ-ሰር የተመዘነ",
maxPoints: "ከፍተኛ ውጤት", maxPoints: "ከፍተኛ ውጤት",
score: "ውጤት", score: "ውጤት",
remark: "ማስታወሻ", remark: "ማስታወሻ",

View File

@@ -657,6 +657,9 @@ export const en = {
seafarerPlaceholder: 'Search and select a seafarer', seafarerPlaceholder: 'Search and select a seafarer',
scorePerQuestion: 'Score per Question', scorePerQuestion: 'Score per Question',
question: 'Question', question: 'Question',
candidateAnswer: "Candidate's Answer",
noAnswer: 'No answer on file',
autoGraded: 'Auto-graded',
maxPoints: 'Max Points', maxPoints: 'Max Points',
score: 'Score', score: 'Score',
remark: 'Remark', remark: 'Remark',