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 { 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<string, string>;
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<string, GradingSheetQuestion>;
},
): AdvancedColumn<QuestionBrief>[] {
return [
@@ -22,6 +25,20 @@ export function recordResultColumns(
</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'),
cell: ({ row }) => (
@@ -32,7 +49,10 @@ export function recordResultColumns(
},
{
header: t('result.recordModal.score'),
cell: ({ row }) => (
cell: ({ row }) => {
const autoGraded = handlers.answersByQuestion.get(row.original.id)?.autoScore != null;
return (
<Group gap={4} wrap="nowrap">
<NumberInput
value={handlers.scores[row.original.id] ?? 0}
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
@@ -41,7 +61,14 @@ export function recordResultColumns(
size="xs"
style={{ width: 80 }}
/>
),
{autoGraded && (
<Badge size="xs" variant="light" color="teal">
{t('result.recordModal.autoGraded')}
</Badge>
)}
</Group>
);
},
},
{
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 {
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<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 ?? [])
.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}

View File

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

View File

@@ -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',