mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 11:55:43 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -5,6 +5,12 @@ import type {
|
||||
CreateExamPayload,
|
||||
UpdateExamPayload,
|
||||
AssignQuestionsPayload,
|
||||
RandomQuestionsPayload,
|
||||
ExamRegistration,
|
||||
RecordAttendancePayload,
|
||||
ExamIncident,
|
||||
CreateIncidentPayload,
|
||||
ResolveIncidentPayload,
|
||||
} from '../types/exam';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
@@ -41,6 +47,49 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Server-side draw from the approved bank (US-EXAM-005). */
|
||||
selectRandomQuestions: builder.mutation<Exam, RandomQuestionsPayload>({
|
||||
query: ({ examId, count }) => ({
|
||||
url: `/exams/${examId}/questions/random`,
|
||||
method: 'POST',
|
||||
body: { count },
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
// --- Candidates and attendance (US-EXAM-007/009) ---------------------
|
||||
getExamRegistrations: builder.query<ExamRegistration[], string>({
|
||||
query: (examId) => `/exams/${examId}/registrations`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
recordAttendance: builder.mutation<ExamRegistration, RecordAttendancePayload>({
|
||||
query: ({ registrationId, ...body }) => ({
|
||||
url: `/exams/registrations/${registrationId}/attendance`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
// --- Session incidents (US-EXAM-010) ---------------------------------
|
||||
getExamIncidents: builder.query<ExamIncident[], string>({
|
||||
query: (examId) => `/exams/${examId}/incidents`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
recordIncident: builder.mutation<ExamIncident, CreateIncidentPayload>({
|
||||
query: ({ examId, ...body }) => ({
|
||||
url: `/exams/${examId}/incidents`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
resolveIncident: builder.mutation<ExamIncident, ResolveIncidentPayload>({
|
||||
query: ({ incidentId, ...body }) => ({
|
||||
url: `/exams/incidents/${incidentId}/resolve`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -52,4 +101,10 @@ export const {
|
||||
useUpdateExamMutation,
|
||||
useDeleteExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
useSelectRandomQuestionsMutation,
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordAttendanceMutation,
|
||||
useGetExamIncidentsQuery,
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
} = examApi;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconUserCheck } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordAttendanceMutation,
|
||||
} from '../api/exam-api';
|
||||
import type { AttendanceStatus, ExamRegistration } from '../types/exam';
|
||||
|
||||
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
REGISTERED: 'gray',
|
||||
PRESENT: 'teal',
|
||||
LATE: 'yellow',
|
||||
ABSENT: 'red',
|
||||
WITHDRAWN: 'orange',
|
||||
DISQUALIFIED: 'red',
|
||||
};
|
||||
|
||||
/** Rulings that end the sitting, and so must be explained (US-EXAM-009). */
|
||||
const NEEDS_REMARK: AttendanceStatus[] = ['WITHDRAWN', 'DISQUALIFIED'];
|
||||
|
||||
const OPTIONS: AttendanceStatus[] = [
|
||||
'PRESENT',
|
||||
'LATE',
|
||||
'ABSENT',
|
||||
'WITHDRAWN',
|
||||
'DISQUALIFIED',
|
||||
];
|
||||
|
||||
/**
|
||||
* The invigilator's register for one session (US-EXAM-009).
|
||||
*
|
||||
* Marking a paper later depends on what is recorded here: an absent,
|
||||
* withdrawn or disqualified candidate has no result to enter.
|
||||
*/
|
||||
export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: registrations, isError } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
|
||||
const [target, setTarget] = useState<ExamRegistration | null>(null);
|
||||
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
const candidateName = (registration: ExamRegistration) =>
|
||||
[
|
||||
registration.profile?.firstName,
|
||||
registration.profile?.middleName,
|
||||
registration.profile?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || registration.profileId.slice(0, 8);
|
||||
|
||||
const save = async () => {
|
||||
if (!target) return;
|
||||
if (NEEDS_REMARK.includes(status) && !remark.trim()) {
|
||||
notify.error(t('exam.candidates.remarkRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await recordAttendance({
|
||||
registrationId: target.id,
|
||||
status,
|
||||
remark: remark.trim() || undefined,
|
||||
}).unwrap();
|
||||
notify.success(t('exam.candidates.recorded'));
|
||||
setTarget(null);
|
||||
setRemark('');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.incidents.error')));
|
||||
}
|
||||
};
|
||||
|
||||
// Officers without the invigilation permission simply do not see the
|
||||
// register; the endpoint refuses them and there is nothing to show.
|
||||
if (isError) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={5} mb="md">
|
||||
{t('exam.candidates.section')}
|
||||
</Title>
|
||||
{(registrations ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t('exam.candidates.none')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('exam.candidates.admission')}</Table.Th>
|
||||
<Table.Th>{t('exam.candidates.name')}</Table.Th>
|
||||
<Table.Th>{t('exam.candidates.attempt')}</Table.Th>
|
||||
<Table.Th>{t('exam.candidates.attendance')}</Table.Th>
|
||||
<Table.Th>{t('exam.candidates.remark')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(registrations ?? []).map((registration) => (
|
||||
<Table.Tr key={registration.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" ff="monospace" fw={600}>
|
||||
{registration.admissionNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{candidateName(registration)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
>
|
||||
{registration.kind === 'RETAKE'
|
||||
? t('exam.candidates.retake', { n: registration.attemptNumber })
|
||||
: t('exam.candidates.firstSitting')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.attendance.${registration.attendanceStatus}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" c="dimmed" maw={220} lineClamp={2}>
|
||||
{registration.attendanceRemark ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconUserCheck size={12} />}
|
||||
onClick={() => {
|
||||
setTarget(registration);
|
||||
setStatus(
|
||||
registration.attendanceStatus === 'REGISTERED'
|
||||
? 'PRESENT'
|
||||
: registration.attendanceStatus,
|
||||
);
|
||||
setRemark(registration.attendanceRemark ?? '');
|
||||
}}
|
||||
>
|
||||
{t('exam.candidates.record')}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={Boolean(target)}
|
||||
onClose={() => setTarget(null)}
|
||||
title={t('exam.candidates.attendance')}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" fw={500}>
|
||||
{target ? candidateName(target) : ''} · {target?.admissionNumber}
|
||||
</Text>
|
||||
<Select
|
||||
label={t('exam.candidates.attendance')}
|
||||
data={OPTIONS.map((value) => ({
|
||||
value,
|
||||
label: t(`exam.attendance.${value}`),
|
||||
}))}
|
||||
value={status}
|
||||
onChange={(value) => setStatus((value as AttendanceStatus) ?? 'PRESENT')}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
label={t('exam.candidates.remark')}
|
||||
minRows={2}
|
||||
autosize
|
||||
value={remark}
|
||||
onChange={(event) => setRemark(event.currentTarget.value)}
|
||||
required={NEEDS_REMARK.includes(status)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setTarget(null)}>
|
||||
{t('exam.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isLoading} onClick={save}>
|
||||
{t('exam.candidates.record')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamIncidentsQuery,
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
} from '../api/exam-api';
|
||||
import type {
|
||||
ExamIncident,
|
||||
ExamIncidentStatus,
|
||||
ExamIncidentType,
|
||||
} from '../types/exam';
|
||||
|
||||
const TYPES: ExamIncidentType[] = [
|
||||
'MISCONDUCT',
|
||||
'TECHNICAL_FAILURE',
|
||||
'MEDICAL',
|
||||
'ADMINISTRATIVE',
|
||||
'OTHER',
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
OPEN: 'red',
|
||||
UNDER_REVIEW: 'yellow',
|
||||
RESOLVED: 'teal',
|
||||
DISMISSED: 'gray',
|
||||
};
|
||||
|
||||
/**
|
||||
* The session incident log (US-EXAM-010). Invigilators file entries during the
|
||||
* sitting; a supervisor closes each one with a written ruling that stays on
|
||||
* the record beside the original report.
|
||||
*/
|
||||
export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: incidents, isError } = useGetExamIncidentsQuery(examId);
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation();
|
||||
const [resolveIncident, { isLoading: isResolving }] = useResolveIncidentMutation();
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [type, setType] = useState<ExamIncidentType>('MISCONDUCT');
|
||||
const [registrationId, setRegistrationId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const [resolveTarget, setResolveTarget] = useState<ExamIncident | null>(null);
|
||||
const [outcome, setOutcome] = useState<'RESOLVED' | 'DISMISSED'>('RESOLVED');
|
||||
const [resolution, setResolution] = useState('');
|
||||
|
||||
const candidateOptions = (registrations ?? []).map((registration) => ({
|
||||
value: registration.id,
|
||||
label: `${registration.admissionNumber} — ${[
|
||||
registration.profile?.firstName,
|
||||
registration.profile?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}`,
|
||||
}));
|
||||
|
||||
const file = async () => {
|
||||
if (!description.trim()) {
|
||||
notify.error(t('exam.incidents.description'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await recordIncident({
|
||||
examId,
|
||||
type,
|
||||
registrationId: registrationId ?? undefined,
|
||||
description: description.trim(),
|
||||
}).unwrap();
|
||||
notify.success(t('exam.incidents.filed'));
|
||||
setAddOpen(false);
|
||||
setDescription('');
|
||||
setRegistrationId(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.incidents.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
if (!resolveTarget || !resolution.trim()) {
|
||||
notify.error(t('exam.incidents.resolution'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await resolveIncident({
|
||||
incidentId: resolveTarget.id,
|
||||
outcome,
|
||||
resolution: resolution.trim(),
|
||||
}).unwrap();
|
||||
notify.success(t('exam.incidents.resolved'));
|
||||
setResolveTarget(null);
|
||||
setResolution('');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.incidents.error')));
|
||||
}
|
||||
};
|
||||
|
||||
// Hidden entirely from officers without the incident permission — the
|
||||
// endpoint refuses them, so there is nothing to render.
|
||||
if (isError) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={5}>{t('exam.incidents.section')}</Title>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => setAddOpen(true)}
|
||||
>
|
||||
{t('exam.incidents.add')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{(incidents ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t('exam.incidents.none')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('exam.incidents.type')}</Table.Th>
|
||||
<Table.Th>{t('exam.incidents.candidate')}</Table.Th>
|
||||
<Table.Th>{t('exam.incidents.description')}</Table.Th>
|
||||
<Table.Th>{t('exam.incidents.occurred')}</Table.Th>
|
||||
<Table.Th>{t('exam.incidents.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(incidents ?? []).map((incident) => (
|
||||
<Table.Tr key={incident.id}>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{t(`exam.incidentType.${incident.type}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs">
|
||||
{incident.registration?.admissionNumber ??
|
||||
t('exam.incidents.wholeRoom')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" maw={260} lineClamp={2}>
|
||||
{incident.description}
|
||||
</Text>
|
||||
{incident.resolution && (
|
||||
<Text fz="xs" c="dimmed" maw={260} lineClamp={2}>
|
||||
⤷ {incident.resolution}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs">{incident.occurredAt?.slice(0, 10)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[incident.status] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.incidentStatus.${incident.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{(incident.status === 'OPEN' ||
|
||||
incident.status === 'UNDER_REVIEW') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
onClick={() => {
|
||||
setResolveTarget(incident);
|
||||
setOutcome('RESOLVED');
|
||||
setResolution('');
|
||||
}}
|
||||
>
|
||||
{t('exam.incidents.resolve')}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
title={t('exam.incidents.add')}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t('exam.incidents.type')}
|
||||
data={TYPES.map((value) => ({
|
||||
value,
|
||||
label: t(`exam.incidentType.${value}`),
|
||||
}))}
|
||||
value={type}
|
||||
onChange={(value) => setType((value as ExamIncidentType) ?? 'OTHER')}
|
||||
size="sm"
|
||||
/>
|
||||
<Select
|
||||
label={t('exam.incidents.candidate')}
|
||||
placeholder={t('exam.incidents.wholeRoom')}
|
||||
data={candidateOptions}
|
||||
value={registrationId}
|
||||
onChange={setRegistrationId}
|
||||
size="sm"
|
||||
clearable
|
||||
searchable
|
||||
/>
|
||||
<Textarea
|
||||
label={t('exam.incidents.description')}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setAddOpen(false)}>
|
||||
{t('exam.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isFiling} onClick={file}>
|
||||
{t('exam.incidents.add')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(resolveTarget)}
|
||||
onClose={() => setResolveTarget(null)}
|
||||
title={t('exam.incidents.resolve')}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm">{resolveTarget?.description}</Text>
|
||||
<Select
|
||||
label={t('exam.incidents.outcome')}
|
||||
data={[
|
||||
{ value: 'RESOLVED', label: t('exam.incidentStatus.RESOLVED') },
|
||||
{ value: 'DISMISSED', label: t('exam.incidentStatus.DISMISSED') },
|
||||
]}
|
||||
value={outcome}
|
||||
onChange={(value) =>
|
||||
setOutcome((value as 'RESOLVED' | 'DISMISSED') ?? 'RESOLVED')
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
label={t('exam.incidents.resolution')}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={resolution}
|
||||
onChange={(event) => setResolution(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setResolveTarget(null)}>
|
||||
{t('exam.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isResolving} onClick={close}>
|
||||
{t('exam.incidents.resolve')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -38,19 +38,22 @@ import {
|
||||
IconUser,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDetails
|
||||
} from "@tabler/icons-react";
|
||||
import { notify, useErrorHandler, ModalFooter } from "@ema-platform/ui";
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamQuery,
|
||||
useUpdateExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
} from "../api/exam-api";
|
||||
import { useGetQuestionsQuery } from "../../question/api/question-api";
|
||||
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
|
||||
import { QuestionAssigner } from "../components/QuestionAssigner";
|
||||
import { RecordResultModal } from "../../result/components/RecordResultModal";
|
||||
import type { ExamStatus, QuestionBrief, actionTypes } from "../types/exam";
|
||||
useSelectRandomQuestionsMutation,
|
||||
} from '../api/exam-api';
|
||||
import { useGetQuestionsQuery } from '../../question/api/question-api';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
import { QuestionAssigner } from '../components/QuestionAssigner';
|
||||
import { RecordResultModal } from '../../result/components/RecordResultModal';
|
||||
import { ExamCandidatesPanel } from '../components/ExamCandidatesPanel';
|
||||
import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
@@ -100,31 +103,27 @@ export function ExamDetailPage() {
|
||||
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
|
||||
const [randomCount, setRandomCount] = useState(5);
|
||||
const [updateExam] = useUpdateExamMutation();
|
||||
const [assignQuestions, { isLoading: isAssigning }] =
|
||||
useAssignQuestionsMutation();
|
||||
const {
|
||||
data: exam,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useGetExamQuery(id ?? "", { skip: !id });
|
||||
const [assignQuestions, { isLoading: isAssigning }] = useAssignQuestionsMutation();
|
||||
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
|
||||
|
||||
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
|
||||
const { data: qRes } = useGetQuestionsQuery();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const allQuestions = qRes?.items ?? [];
|
||||
const certifications = certRes?.items ?? [];
|
||||
const [whatAction, setWhatAction] = useState<actionTypes>();
|
||||
|
||||
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
|
||||
// must not offer drafts or retired questions either.
|
||||
const eligibleQuestions = useMemo(() => {
|
||||
if (!exam) return [];
|
||||
return allQuestions
|
||||
.filter(
|
||||
(q) =>
|
||||
q.certificationId === exam.certificationId && q.form === exam.form,
|
||||
q.certificationId === exam.certificationId &&
|
||||
q.form === exam.form &&
|
||||
q.status === 'APPROVED',
|
||||
)
|
||||
.map((q) => ({
|
||||
id: q.id,
|
||||
title: q.title,
|
||||
form: q.form,
|
||||
points: q.points,
|
||||
}));
|
||||
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allQuestions, exam?.certificationId, exam?.form]);
|
||||
|
||||
@@ -158,75 +157,40 @@ export function ExamDetailPage() {
|
||||
openAssign();
|
||||
};
|
||||
|
||||
const handleRandomSelect = () => {
|
||||
const assignedIds = new Set(draftQuestions.map((q) => q.id));
|
||||
const currentTotal = draftQuestions.reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
const cuttingPoint = Number(exam.cuttingPoint);
|
||||
const eligible = eligibleQuestions.filter((q) => !assignedIds.has(q.id));
|
||||
|
||||
if (eligible.length === 0) {
|
||||
notify.error("No eligible questions available for random selection");
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPossible =
|
||||
currentTotal + eligible.reduce((s, q) => s + Number(q.points), 0);
|
||||
if (maxPossible < cuttingPoint) {
|
||||
/**
|
||||
* The draw happens on the server (US-EXAM-005): it picks only approved
|
||||
* items for this subject and writes the paper in one call, so the pool is
|
||||
* never shipped to the browser and cannot be reshuffled until it flatters.
|
||||
*/
|
||||
const handleRandomSelect = async () => {
|
||||
try {
|
||||
const updated = await selectRandom({ examId: exam.id, count: randomCount }).unwrap();
|
||||
setDraftQuestions(updated.questions ?? []);
|
||||
notify.success(t('exam.randomSelected', { count: (updated.questions ?? []).length }));
|
||||
closeAssign();
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('exam.randomError'));
|
||||
notify.error(
|
||||
`Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`,
|
||||
key.startsWith('insufficient_approved_questions')
|
||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||
: key,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const shuffled = [...eligible].sort(() => Math.random() - 0.5);
|
||||
const targetCount = Math.min(randomCount, shuffled.length);
|
||||
const picked = shuffled.slice(0, targetCount);
|
||||
let pickedTotal = picked.reduce((s, q) => s + Number(q.points), 0);
|
||||
|
||||
if (currentTotal + pickedTotal < cuttingPoint) {
|
||||
const remaining = shuffled.slice(targetCount);
|
||||
for (const q of remaining) {
|
||||
if (currentTotal + pickedTotal >= cuttingPoint) break;
|
||||
picked.push(q);
|
||||
pickedTotal += q.points;
|
||||
}
|
||||
}
|
||||
|
||||
const msg =
|
||||
picked.length > targetCount
|
||||
? `Selected ${picked.length} questions (${picked.length - targetCount} extra added to meet the ${cuttingPoint} pts passing mark)`
|
||||
: `Randomly selected ${picked.length} questions`;
|
||||
|
||||
setDraftQuestions([...draftQuestions, ...picked]);
|
||||
notify.info(msg);
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
try {
|
||||
const questionIds = draftQuestions.map((q) => q.id);
|
||||
if (whatAction === "add") {
|
||||
await assignQuestions({
|
||||
examId: exam.id,
|
||||
questionIds,
|
||||
remark: undefined,
|
||||
}).unwrap();
|
||||
notify.success("Questions assigned");
|
||||
closeAssign();
|
||||
} else if (whatAction === "remove") {
|
||||
// await removeQuestions({
|
||||
// examId: exam.id,
|
||||
// questionIds,
|
||||
// remark: undefined,
|
||||
// });
|
||||
// notify.success("Questions removed");
|
||||
// closeAssign();
|
||||
notify.warning("no action yet");
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap();
|
||||
notify.success('Questions assigned');
|
||||
closeAssign();
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Failed to assign questions');
|
||||
notify.error(
|
||||
key.startsWith('question_not_approved')
|
||||
? t('question.qc.onlyApprovedUsable')
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
const handlePrint = async () => {
|
||||
@@ -470,11 +434,11 @@ export function ExamDetailPage() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<RecordResultModal
|
||||
exam={exam}
|
||||
opened={recordOpened}
|
||||
onClose={closeRecord}
|
||||
/>
|
||||
{/* Exam-day operations: who sat the paper, and what went wrong */}
|
||||
<ExamCandidatesPanel examId={exam.id} />
|
||||
<ExamIncidentsPanel examId={exam.id} />
|
||||
|
||||
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
|
||||
|
||||
{/* Question assignment modal */}
|
||||
<Modal
|
||||
@@ -505,24 +469,18 @@ export function ExamDetailPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("exam.assigner.randomHint", {
|
||||
total: eligibleQuestions.length,
|
||||
pts: exam.cuttingPoint,
|
||||
})}
|
||||
</Text>
|
||||
<Text fz="sm" c="dimmed">{t('exam.randomHintServer')}</Text>
|
||||
<Group gap="sm">
|
||||
<NumberInput
|
||||
placeholder={t("exam.assigner.selectCount")}
|
||||
value={randomCount}
|
||||
onChange={(v) => setRandomCount(Number(v))}
|
||||
min={1}
|
||||
max={eligibleQuestions.length}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<Button size="xs" variant="light" onClick={handleRandomSelect}>
|
||||
{t("exam.randomSelect")}
|
||||
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
|
||||
{t('exam.randomSelect')}
|
||||
</Button>
|
||||
</Group>
|
||||
<QuestionAssigner
|
||||
|
||||
@@ -85,4 +85,83 @@ export interface AssignQuestionsPayload {
|
||||
questionIds: string[];
|
||||
remark?: LocalePair;
|
||||
}
|
||||
export type actionTypes = "add" | "remove";
|
||||
|
||||
export interface RandomQuestionsPayload {
|
||||
examId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** What the invigilator recorded on the day (US-EXAM-009). */
|
||||
export type AttendanceStatus =
|
||||
| 'REGISTERED'
|
||||
| 'PRESENT'
|
||||
| 'ABSENT'
|
||||
| 'LATE'
|
||||
| 'WITHDRAWN'
|
||||
| 'DISQUALIFIED';
|
||||
|
||||
export interface ExamRegistration {
|
||||
id: string;
|
||||
examId: string;
|
||||
profileId: string;
|
||||
admissionNumber: string;
|
||||
kind: 'NEW' | 'RETAKE';
|
||||
attemptNumber: number;
|
||||
attendanceStatus: AttendanceStatus;
|
||||
attendanceRemark: string | null;
|
||||
attendanceRecordedAt: string | null;
|
||||
createdAt: string;
|
||||
profile?: {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
middleName: string | null;
|
||||
lastName: string | null;
|
||||
seafarerNumber: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecordAttendancePayload {
|
||||
registrationId: string;
|
||||
status: AttendanceStatus;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export type ExamIncidentType =
|
||||
| 'MISCONDUCT'
|
||||
| 'TECHNICAL_FAILURE'
|
||||
| 'MEDICAL'
|
||||
| 'ADMINISTRATIVE'
|
||||
| 'OTHER';
|
||||
|
||||
export type ExamIncidentStatus =
|
||||
| 'OPEN'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'RESOLVED'
|
||||
| 'DISMISSED';
|
||||
|
||||
export interface ExamIncident {
|
||||
id: string;
|
||||
examId: string;
|
||||
registrationId: string | null;
|
||||
type: ExamIncidentType;
|
||||
occurredAt: string;
|
||||
description: string;
|
||||
status: ExamIncidentStatus;
|
||||
resolution: string | null;
|
||||
resolvedAt: string | null;
|
||||
registration?: ExamRegistration;
|
||||
}
|
||||
|
||||
export interface CreateIncidentPayload {
|
||||
examId: string;
|
||||
type: ExamIncidentType;
|
||||
registrationId?: string;
|
||||
description: string;
|
||||
occurredAt?: string;
|
||||
}
|
||||
|
||||
export interface ResolveIncidentPayload {
|
||||
incidentId: string;
|
||||
outcome: 'RESOLVED' | 'DISMISSED';
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user