mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
@@ -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'),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -657,6 +657,9 @@ export const am: Translations = {
|
|||||||
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
|
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
|
||||||
scorePerQuestion: "በጥያቄ ውጤት",
|
scorePerQuestion: "በጥያቄ ውጤት",
|
||||||
question: "ጥያቄ",
|
question: "ጥያቄ",
|
||||||
|
candidateAnswer: "የተፈታኙ መልስ",
|
||||||
|
noAnswer: "የተመዘገበ መልስ የለም",
|
||||||
|
autoGraded: "በራስ-ሰር የተመዘነ",
|
||||||
maxPoints: "ከፍተኛ ውጤት",
|
maxPoints: "ከፍተኛ ውጤት",
|
||||||
score: "ውጤት",
|
score: "ውጤት",
|
||||||
remark: "ማስታወሻ",
|
remark: "ማስታወሻ",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user