mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 05:58:19 +00:00
Merge pull request #32 from Tria-plc/feature/exam-attempt-domain
Feature/exam attempt domain
This commit is contained in:
@@ -12,6 +12,7 @@ import type {
|
|||||||
CreateIncidentPayload,
|
CreateIncidentPayload,
|
||||||
ResolveIncidentPayload,
|
ResolveIncidentPayload,
|
||||||
RegradeOutcome,
|
RegradeOutcome,
|
||||||
|
GradingSheet,
|
||||||
} from '../types/exam';
|
} from '../types/exam';
|
||||||
|
|
||||||
const examApi = baseApi.injectEndpoints({
|
const examApi = baseApi.injectEndpoints({
|
||||||
@@ -101,6 +102,15 @@ const examApi = baseApi.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
invalidatesTags: ['Api'],
|
invalidatesTags: ['Api'],
|
||||||
}),
|
}),
|
||||||
|
/** The candidate's answers plus auto-computable CHOICE scores, for RecordResultModal. */
|
||||||
|
getGradingSheet: builder.query<
|
||||||
|
GradingSheet,
|
||||||
|
{ examId: string; profileId: string }
|
||||||
|
>({
|
||||||
|
query: ({ examId, profileId }) =>
|
||||||
|
`/exam-attempts/exam/${examId}/candidate/${profileId}/grading-sheet`,
|
||||||
|
providesTags: ['Api'],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@@ -119,4 +129,5 @@ export const {
|
|||||||
useRecordIncidentMutation,
|
useRecordIncidentMutation,
|
||||||
useResolveIncidentMutation,
|
useResolveIncidentMutation,
|
||||||
useRegradeAttemptMutation,
|
useRegradeAttemptMutation,
|
||||||
|
useGetGradingSheetQuery,
|
||||||
} = examApi;
|
} = examApi;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Box,
|
Box,
|
||||||
|
Tooltip,
|
||||||
rem,
|
rem,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
useUpdateExamMutation,
|
useUpdateExamMutation,
|
||||||
useAssignQuestionsMutation,
|
useAssignQuestionsMutation,
|
||||||
useSelectRandomQuestionsMutation,
|
useSelectRandomQuestionsMutation,
|
||||||
|
useGetExamRegistrationsQuery,
|
||||||
} from '../api/exam-api';
|
} from '../api/exam-api';
|
||||||
import { useGetQuestionsQuery } from '../../question/api/question-api';
|
import { useGetQuestionsQuery } from '../../question/api/question-api';
|
||||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||||
@@ -114,6 +116,12 @@ export function ExamDetailPage() {
|
|||||||
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
|
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
|
||||||
|
|
||||||
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
|
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
|
||||||
|
// The backend locks the paper the moment the first candidate registers
|
||||||
|
// (ExamService.assertPaperEditable) — every candidate must sit the same
|
||||||
|
// paper. Same query ExamCandidatesPanel already runs, so RTK Query serves
|
||||||
|
// it from cache rather than issuing a second request.
|
||||||
|
const { data: registrations } = useGetExamRegistrationsQuery(id ?? '', { skip: !id });
|
||||||
|
const paperLocked = (registrations?.length ?? 0) > 0;
|
||||||
const { data: qRes } = useGetQuestionsQuery();
|
const { data: qRes } = useGetQuestionsQuery();
|
||||||
const { data: certRes } = useGetCertificationsQuery();
|
const { data: certRes } = useGetCertificationsQuery();
|
||||||
const allQuestions = qRes?.items ?? [];
|
const allQuestions = qRes?.items ?? [];
|
||||||
@@ -122,10 +130,14 @@ export function ExamDetailPage() {
|
|||||||
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
|
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
|
||||||
// must not offer drafts or retired questions either.
|
// must not offer drafts or retired questions either.
|
||||||
//
|
//
|
||||||
// BOTH describes a mixed paper — a question itself is never "BOTH" (see
|
// Filters on exam.form alone, not administrationMethod: the backend no
|
||||||
|
// longer restricts ONLINE to CHOICE (ExamService no longer has an
|
||||||
|
// assertOnlineIsChoiceOnly gate), so exam.form is now the sole source of
|
||||||
|
// truth for what belongs on the paper, ONLINE or OFFLINE alike. BOTH
|
||||||
|
// describes a mixed paper — a question itself is never "BOTH" (see
|
||||||
// QuestionForm), so an equality check against it would match nothing and
|
// QuestionForm), so an equality check against it would match nothing and
|
||||||
// silently offer zero questions. Same skip-condition as the backend's own
|
// silently offer zero questions; skipped the same way the backend's own
|
||||||
// random draw (ExamService.selectRandomQuestions).
|
// random draw does (ExamService.selectRandomQuestions).
|
||||||
const eligibleQuestions = useMemo(() => {
|
const eligibleQuestions = useMemo(() => {
|
||||||
if (!exam) return [];
|
if (!exam) return [];
|
||||||
return allQuestions
|
return allQuestions
|
||||||
@@ -179,7 +191,9 @@ export function ExamDetailPage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const key = extractErrorMessage(error, t('exam.randomError'));
|
const key = extractErrorMessage(error, t('exam.randomError'));
|
||||||
notify.error(
|
notify.error(
|
||||||
key.startsWith('insufficient_approved_questions')
|
key === 'paper_locked_after_registration'
|
||||||
|
? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
|
||||||
|
: key.startsWith('insufficient_approved_questions')
|
||||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||||
? t('exam.cannotReachCuttingPoint', {
|
? t('exam.cannotReachCuttingPoint', {
|
||||||
@@ -200,7 +214,9 @@ export function ExamDetailPage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const key = extractErrorMessage(error, 'Failed to assign questions');
|
const key = extractErrorMessage(error, 'Failed to assign questions');
|
||||||
notify.error(
|
notify.error(
|
||||||
key.startsWith('question_not_approved')
|
key === 'paper_locked_after_registration'
|
||||||
|
? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
|
||||||
|
: key.startsWith('question_not_approved')
|
||||||
? t('question.qc.onlyApprovedUsable')
|
? t('question.qc.onlyApprovedUsable')
|
||||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||||
? t('exam.cannotReachCuttingPoint', {
|
? t('exam.cannotReachCuttingPoint', {
|
||||||
@@ -452,19 +468,45 @@ export function ExamDetailPage() {
|
|||||||
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
||||||
</Title>
|
</Title>
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||||
<Button
|
<Group gap="xs">
|
||||||
variant="light"
|
{paperLocked && (
|
||||||
size="xs"
|
<Badge size="sm" variant="light" color="gray">
|
||||||
leftSection={<IconPlus size={14} />}
|
{t("exam.paperLocked")}
|
||||||
onClick={openAssignModal}
|
</Badge>
|
||||||
>
|
)}
|
||||||
{t("exam.manageQuestions")}
|
<Tooltip
|
||||||
</Button>
|
label={t("exam.paperLockedHint", {
|
||||||
|
count: registrations?.length ?? 0,
|
||||||
|
})}
|
||||||
|
disabled={!paperLocked}
|
||||||
|
multiline
|
||||||
|
w={280}
|
||||||
|
>
|
||||||
|
{/* Wrapped: a disabled Mantine Button fires no pointer events,
|
||||||
|
so the tooltip needs an enabled element to hang off. */}
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconPlus size={14} />}
|
||||||
|
onClick={openAssignModal}
|
||||||
|
disabled={paperLocked}
|
||||||
|
>
|
||||||
|
{t("exam.manageQuestions")}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
{(exam.questions ?? []).length === 0 ? (
|
{(exam.questions ?? []).length === 0 ? (
|
||||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
<Alert
|
||||||
{t("exam.noQuestionsAssigned")}
|
color={paperLocked ? "red" : "gray"}
|
||||||
|
icon={<IconInfoCircle size={16} />}
|
||||||
|
>
|
||||||
|
{paperLocked
|
||||||
|
? t("exam.paperLockedHint", { count: registrations?.length ?? 0 })
|
||||||
|
: t("exam.noQuestionsAssigned")}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
|||||||
@@ -79,21 +79,44 @@ function ExamForm({
|
|||||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||||
const [activeTab, setActiveTab] = useState<string | null>("basic");
|
const [activeTab, setActiveTab] = useState<string | null>("basic");
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
/**
|
||||||
e.preventDefault();
|
* Split per tab so "Next" can check just the tab in front of the user.
|
||||||
|
* Submitting from Basic Info used to complain about Settings fields the
|
||||||
|
* user had not been shown yet — the error was correct and unactionable at
|
||||||
|
* the same time. Each returns the message key for what is missing, or null.
|
||||||
|
*/
|
||||||
|
const validateBasic = (): string | null => {
|
||||||
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
|
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
|
||||||
setActiveTab("basic");
|
return "exam.form.fillRequiredBasic";
|
||||||
notify.error(t("exam.form.fillRequiredBasic"));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
||||||
setActiveTab("basic");
|
return "exam.form.directionBothLanguages";
|
||||||
notify.error(t("exam.form.directionBothLanguages"));
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateSettings = (): string | null =>
|
||||||
|
!type || !form || !adminMethod || !evalMethod || !cuttingPoint
|
||||||
|
? "exam.form.fillRequiredSettings"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
const error = validateBasic();
|
||||||
|
if (error) {
|
||||||
|
notify.error(t(error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
|
setActiveTab("settings");
|
||||||
setActiveTab("settings");
|
};
|
||||||
notify.error(t("exam.form.fillRequiredSettings"));
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Still checks both: the tabs are clickable, so a user can reach Settings
|
||||||
|
// without going through Next.
|
||||||
|
const error = validateBasic() ?? validateSettings();
|
||||||
|
if (error) {
|
||||||
|
setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic");
|
||||||
|
notify.error(t(error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSubmit(
|
onSubmit(
|
||||||
@@ -255,12 +278,6 @@ function ExamForm({
|
|||||||
onChange={setForm}
|
onChange={setForm}
|
||||||
size="sm"
|
size="sm"
|
||||||
required
|
required
|
||||||
disabled={adminMethod === "ONLINE"}
|
|
||||||
description={
|
|
||||||
adminMethod === "ONLINE"
|
|
||||||
? t("exam.form.onlineChoiceOnlyHint")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label={t("exam.detail.administration")}
|
label={t("exam.detail.administration")}
|
||||||
@@ -270,14 +287,7 @@ function ExamForm({
|
|||||||
{ value: "ONLINE", label: t("exam.form.online") },
|
{ value: "ONLINE", label: t("exam.form.online") },
|
||||||
]}
|
]}
|
||||||
value={adminMethod}
|
value={adminMethod}
|
||||||
onChange={(value) => {
|
onChange={setAdminMethod}
|
||||||
setAdminMethod(value);
|
|
||||||
// Online exams are graded automatically, and that only
|
|
||||||
// has an answer model for CHOICE — matches the backend
|
|
||||||
// rule (online_exam_requires_choice_form), not just a
|
|
||||||
// UI nicety.
|
|
||||||
if (value === "ONLINE") setForm("CHOICE");
|
|
||||||
}}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -350,9 +360,26 @@ function ExamForm({
|
|||||||
<Button variant="default" onClick={onCancel} size="sm">
|
<Button variant="default" onClick={onCancel} size="sm">
|
||||||
{t("exam.cancel")}
|
{t("exam.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
{activeTab === "basic" ? (
|
||||||
{editing ? t("exam.update") : t("exam.create")}
|
/* Not type="submit": Basic Info is not the last step, so the
|
||||||
</Button>
|
primary action advances rather than saves. */
|
||||||
|
<Button size="sm" onClick={goNext}>
|
||||||
|
{t("exam.form.next")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab("basic")}
|
||||||
|
>
|
||||||
|
{t("exam.form.back")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||||
|
{editing ? t("exam.update") : t("exam.create")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -133,6 +133,24 @@ export interface ExamRegistration {
|
|||||||
export type RegradeOutcome =
|
export type RegradeOutcome =
|
||||||
{ graded: true; resultId: string } | { graded: false; reason: string };
|
{ graded: true; resultId: string } | { graded: false; reason: string };
|
||||||
|
|
||||||
|
/** One question's row on the staff grading sheet — the candidate's own
|
||||||
|
* answer plus the auto-computable score, where one exists. */
|
||||||
|
export interface GradingSheetQuestion {
|
||||||
|
questionId: string;
|
||||||
|
form: QuestionForm;
|
||||||
|
points: number;
|
||||||
|
answerText: string | null;
|
||||||
|
selectedOptionId: string | null;
|
||||||
|
selectedOptionText: { en?: string; am?: string } | null;
|
||||||
|
/** null means "no auto-score" — examiner enters one by hand. */
|
||||||
|
autoScore: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradingSheet {
|
||||||
|
attemptStatus: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
|
||||||
|
questions: GradingSheetQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecordAttendancePayload {
|
export interface RecordAttendancePayload {
|
||||||
registrationId: string;
|
registrationId: string;
|
||||||
status: AttendanceStatus;
|
status: AttendanceStatus;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -260,7 +260,6 @@ export const am: Translations = {
|
|||||||
both: "ሁለቱም",
|
both: "ሁለቱም",
|
||||||
offline: "ከመስመር ውጪ",
|
offline: "ከመስመር ውጪ",
|
||||||
online: "በመስመር",
|
online: "በመስመር",
|
||||||
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
|
|
||||||
sum: "ድምር",
|
sum: "ድምር",
|
||||||
average: "አማካይ",
|
average: "አማካይ",
|
||||||
percentage: "መቶኛ",
|
percentage: "መቶኛ",
|
||||||
@@ -272,6 +271,8 @@ export const am: Translations = {
|
|||||||
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
|
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
|
||||||
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
|
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
|
||||||
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
|
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
|
||||||
|
next: "ቀጣይ",
|
||||||
|
back: "ተመለስ",
|
||||||
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
|
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
|
||||||
status: "ሁኔታ",
|
status: "ሁኔታ",
|
||||||
statusPlaceholder: "የፈተና ሁኔታ",
|
statusPlaceholder: "የፈተና ሁኔታ",
|
||||||
@@ -388,6 +389,9 @@ export const am: Translations = {
|
|||||||
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
|
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
|
||||||
cannotReachCuttingPoint:
|
cannotReachCuttingPoint:
|
||||||
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
|
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
|
||||||
|
paperLocked: "ወረቀቱ ተቆልፏል",
|
||||||
|
paperLockedHint:
|
||||||
|
"ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።",
|
||||||
},
|
},
|
||||||
|
|
||||||
country: {
|
country: {
|
||||||
@@ -658,6 +662,9 @@ export const am: Translations = {
|
|||||||
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
|
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
|
||||||
scorePerQuestion: "በጥያቄ ውጤት",
|
scorePerQuestion: "በጥያቄ ውጤት",
|
||||||
question: "ጥያቄ",
|
question: "ጥያቄ",
|
||||||
|
candidateAnswer: "የተፈታኙ መልስ",
|
||||||
|
noAnswer: "የተመዘገበ መልስ የለም",
|
||||||
|
autoGraded: "በራስ-ሰር የተመዘነ",
|
||||||
maxPoints: "ከፍተኛ ውጤት",
|
maxPoints: "ከፍተኛ ውጤት",
|
||||||
score: "ውጤት",
|
score: "ውጤት",
|
||||||
remark: "ማስታወሻ",
|
remark: "ማስታወሻ",
|
||||||
|
|||||||
@@ -259,7 +259,6 @@ export const en = {
|
|||||||
both: 'Both',
|
both: 'Both',
|
||||||
offline: 'Offline',
|
offline: 'Offline',
|
||||||
online: 'Online',
|
online: 'Online',
|
||||||
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
|
|
||||||
sum: 'Sum',
|
sum: 'Sum',
|
||||||
average: 'Average',
|
average: 'Average',
|
||||||
percentage: 'Percentage',
|
percentage: 'Percentage',
|
||||||
@@ -272,6 +271,8 @@ export const en = {
|
|||||||
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
|
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
|
||||||
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
|
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
|
||||||
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
|
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
|
||||||
|
next: 'Next',
|
||||||
|
back: 'Back',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
statusPlaceholder: 'Exam status',
|
statusPlaceholder: 'Exam status',
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
@@ -387,6 +388,9 @@ export const en = {
|
|||||||
'Not enough approved questions in the bank for this subject.',
|
'Not enough approved questions in the bank for this subject.',
|
||||||
cannotReachCuttingPoint:
|
cannotReachCuttingPoint:
|
||||||
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
|
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
|
||||||
|
paperLocked: 'Paper locked',
|
||||||
|
paperLockedHint:
|
||||||
|
'{{count}} candidate(s) have registered for this session. Every candidate must sit the same paper, so questions can no longer be changed.',
|
||||||
},
|
},
|
||||||
|
|
||||||
country: {
|
country: {
|
||||||
@@ -658,6 +662,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