mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 05:25:43 +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,
|
||||
ResolveIncidentPayload,
|
||||
RegradeOutcome,
|
||||
GradingSheet,
|
||||
} from '../types/exam';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
@@ -101,6 +102,15 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
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,
|
||||
});
|
||||
@@ -119,4 +129,5 @@ export const {
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
useRegradeAttemptMutation,
|
||||
useGetGradingSheetQuery,
|
||||
} = examApi;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
Tooltip,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
useUpdateExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
useSelectRandomQuestionsMutation,
|
||||
useGetExamRegistrationsQuery,
|
||||
} from '../api/exam-api';
|
||||
import { useGetQuestionsQuery } from '../../question/api/question-api';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
@@ -114,6 +116,12 @@ export function ExamDetailPage() {
|
||||
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
|
||||
|
||||
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: certRes } = useGetCertificationsQuery();
|
||||
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
|
||||
// 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
|
||||
// silently offer zero questions. Same skip-condition as the backend's own
|
||||
// random draw (ExamService.selectRandomQuestions).
|
||||
// silently offer zero questions; skipped the same way the backend's own
|
||||
// random draw does (ExamService.selectRandomQuestions).
|
||||
const eligibleQuestions = useMemo(() => {
|
||||
if (!exam) return [];
|
||||
return allQuestions
|
||||
@@ -179,7 +191,9 @@ export function ExamDetailPage() {
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('exam.randomError'));
|
||||
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] ?? ''})`
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
@@ -200,7 +214,9 @@ export function ExamDetailPage() {
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Failed to assign questions');
|
||||
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')
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
@@ -452,19 +468,45 @@ export function ExamDetailPage() {
|
||||
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
||||
</Title>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
{paperLocked && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("exam.paperLocked")}
|
||||
</Badge>
|
||||
)}
|
||||
<Tooltip
|
||||
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>
|
||||
</Group>
|
||||
{(exam.questions ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t("exam.noQuestionsAssigned")}
|
||||
<Alert
|
||||
color={paperLocked ? "red" : "gray"}
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
{paperLocked
|
||||
? t("exam.paperLockedHint", { count: registrations?.length ?? 0 })
|
||||
: t("exam.noQuestionsAssigned")}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -79,21 +79,44 @@ function ExamForm({
|
||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||
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) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.fillRequiredBasic"));
|
||||
return;
|
||||
return "exam.form.fillRequiredBasic";
|
||||
}
|
||||
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.directionBothLanguages"));
|
||||
return "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;
|
||||
}
|
||||
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
|
||||
setActiveTab("settings");
|
||||
notify.error(t("exam.form.fillRequiredSettings"));
|
||||
setActiveTab("settings");
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
onSubmit(
|
||||
@@ -255,12 +278,6 @@ function ExamForm({
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
disabled={adminMethod === "ONLINE"}
|
||||
description={
|
||||
adminMethod === "ONLINE"
|
||||
? t("exam.form.onlineChoiceOnlyHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.administration")}
|
||||
@@ -270,14 +287,7 @@ function ExamForm({
|
||||
{ value: "ONLINE", label: t("exam.form.online") },
|
||||
]}
|
||||
value={adminMethod}
|
||||
onChange={(value) => {
|
||||
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");
|
||||
}}
|
||||
onChange={setAdminMethod}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
@@ -350,9 +360,26 @@ function ExamForm({
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("exam.update") : t("exam.create")}
|
||||
</Button>
|
||||
{activeTab === "basic" ? (
|
||||
/* Not type="submit": Basic Info is not the last step, so the
|
||||
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>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -133,6 +133,24 @@ export interface ExamRegistration {
|
||||
export type RegradeOutcome =
|
||||
{ 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 {
|
||||
registrationId: string;
|
||||
status: AttendanceStatus;
|
||||
|
||||
Reference in New Issue
Block a user