Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-08 09:03:50 +03:00
66 changed files with 6665 additions and 3846 deletions

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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;
}