mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +00:00
Merge pull request #67 from Tria-plc/examination-issue-fix
fix(exam): reflect authoritative exam state and lock engine-graded re…
This commit is contained in:
@@ -13,7 +13,13 @@ import type {
|
||||
ResolveIncidentPayload,
|
||||
RegradeOutcome,
|
||||
GradingSheet,
|
||||
AddExamQuestionsPayload,
|
||||
CreateExamQuestionPayload,
|
||||
ImportExamQuestionsPayload,
|
||||
QuestionImportReport,
|
||||
ExamWaitMetrics,
|
||||
} from '../types/exam';
|
||||
import type { Question } from '../../question/types/question';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
@@ -60,6 +66,48 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
// --- Question management from the exam page ---------------------------
|
||||
/** Append approved bank items to the paper, keeping what is already on it. */
|
||||
addExamQuestions: builder.mutation<Exam, AddExamQuestionsPayload>({
|
||||
query: ({ examId, questionIds }) => ({
|
||||
url: `/exams/${examId}/questions/add`,
|
||||
method: 'POST',
|
||||
body: { questionIds },
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Author a question under the exam's subject and put it on the paper in one call. */
|
||||
createExamQuestion: builder.mutation<Question, CreateExamQuestionPayload>({
|
||||
query: ({ examId, ...body }) => ({
|
||||
url: `/exams/${examId}/questions/new`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/**
|
||||
* Excel import. `dryRun` validates and previews without writing; the real
|
||||
* import runs the same validation and is all-or-nothing on the server.
|
||||
*/
|
||||
importExamQuestions: builder.mutation<QuestionImportReport, ImportExamQuestionsPayload>({
|
||||
query: ({ examId, file, dryRun }) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
// No Content-Type header: fetch sets it with the multipart boundary.
|
||||
return {
|
||||
url: `/exams/${examId}/questions/import?dryRun=${dryRun ? 'true' : 'false'}`,
|
||||
method: 'POST',
|
||||
body,
|
||||
};
|
||||
},
|
||||
// A dry run changes nothing, so the paper does not need refetching.
|
||||
invalidatesTags: (_result, error, { dryRun }) => (error || dryRun ? [] : ['Api']),
|
||||
}),
|
||||
/** Read-only analytics over the session's own timestamps. */
|
||||
getExamWaitMetrics: builder.query<ExamWaitMetrics, string>({
|
||||
query: (examId) => `/exams/${examId}/wait-metrics`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
// --- Candidates and attendance (US-EXAM-007/009) ---------------------
|
||||
getExamRegistrations: builder.query<ExamRegistration[], string>({
|
||||
query: (examId) => `/exams/${examId}/registrations`,
|
||||
@@ -123,6 +171,10 @@ export const {
|
||||
useDeleteExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
useSelectRandomQuestionsMutation,
|
||||
useAddExamQuestionsMutation,
|
||||
useCreateExamQuestionMutation,
|
||||
useImportExamQuestionsMutation,
|
||||
useGetExamWaitMetricsQuery,
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordAttendanceMutation,
|
||||
useGetExamIncidentsQuery,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
|
||||
import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react';
|
||||
import { ActionIcon, Badge, Group, Menu, Text, Tooltip } from '@mantine/core';
|
||||
import { IconDotsVertical, IconLock, IconRefresh, IconUserCheck } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -78,13 +78,59 @@ export function examCandidateColumns(
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
// What the paper came to, if it has been marked — so the invigilator
|
||||
// and the marking officer see at a glance whose result already exists
|
||||
// (and whether the engine produced it, in which case it is locked).
|
||||
header: t('exam.candidates.result'),
|
||||
cell: ({ row }) => {
|
||||
const result = row.original.result;
|
||||
if (!result) {
|
||||
return (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('exam.candidates.noResult')}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const outcome = t(`exam.candidates.outcome.${result.status}`);
|
||||
const review = t(`result.review.${result.reviewStatus}`, result.reviewStatus);
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
result.autoGraded
|
||||
? t('exam.candidates.engineMarked', { review })
|
||||
: t('exam.candidates.examinerMarked', { review })
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={result.status === 'PASSED' ? 'teal' : 'red'}
|
||||
leftSection={result.autoGraded ? <IconLock size={10} /> : undefined}
|
||||
>
|
||||
{outcome} · {result.totalScore}
|
||||
</Badge>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{review}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('exam.candidates.record'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => {
|
||||
const attemptStatus = row.original.attempt?.status;
|
||||
const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED';
|
||||
// Regrading creates a result; once one exists the API refuses
|
||||
// (result_already_recorded), so the action is not offered.
|
||||
const canRegrade =
|
||||
(attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED') &&
|
||||
!row.original.result;
|
||||
return (
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { ModalFooter, notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useCreateExamQuestionMutation } from '../../api/exam-api';
|
||||
import type { Exam, QuestionForm } from '../../types/exam';
|
||||
import { describeExamQuestionError } from './errors';
|
||||
|
||||
type DraftOption = { textEn: string; textAm: string; isCorrect: boolean };
|
||||
|
||||
const BLANK: DraftOption[] = [
|
||||
{ textEn: '', textAm: '', isCorrect: false },
|
||||
{ textEn: '', textAm: '', isCorrect: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* "Add new question from scratch": authored under this exam's subject and
|
||||
* put on its paper in one call — the officer never leaves the exam or copies
|
||||
* an id. The item is a real bank question (options, answer key), reusable on
|
||||
* a later paper.
|
||||
*/
|
||||
export function ExamQuestionCreateModal({
|
||||
exam,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
exam: Exam;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [createQuestion, { isLoading }] = useCreateExamQuestionMutation();
|
||||
const [titleEn, setTitleEn] = useState('');
|
||||
const [titleAm, setTitleAm] = useState('');
|
||||
const [form, setForm] = useState<QuestionForm | null>(exam.form === 'ESSAY' ? 'ESSAY' : 'CHOICE');
|
||||
const [points, setPoints] = useState<number>(1);
|
||||
const [options, setOptions] = useState<DraftOption[]>(BLANK);
|
||||
|
||||
const reset = () => {
|
||||
setTitleEn('');
|
||||
setTitleAm('');
|
||||
setForm(exam.form === 'ESSAY' ? 'ESSAY' : 'CHOICE');
|
||||
setPoints(1);
|
||||
setOptions(BLANK);
|
||||
};
|
||||
const close = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const updateOption = (index: number, patch: Partial<DraftOption>) =>
|
||||
setOptions((current) => current.map((o, i) => (i === index ? { ...o, ...patch } : o)));
|
||||
|
||||
// A mixed (BOTH) paper takes either form; otherwise the question must match.
|
||||
const formOptions = (exam.form === 'BOTH' ? ['ESSAY', 'CHOICE'] : [exam.form]).map((value) => ({
|
||||
value,
|
||||
label: t(`exam.formType.${value}`),
|
||||
}));
|
||||
|
||||
const submit = async () => {
|
||||
if (!titleEn.trim() || !form || !(points > 0)) {
|
||||
notify.error(t('exam.newQuestion.fillRequired'));
|
||||
return;
|
||||
}
|
||||
if (form === 'CHOICE') {
|
||||
if (options.length < 2) return void notify.error(t('exam.newQuestion.needTwo'));
|
||||
if (!options.some((o) => o.isCorrect)) return void notify.error(t('exam.newQuestion.needCorrect'));
|
||||
if (options.some((o) => !o.textEn.trim())) return void notify.error(t('exam.newQuestion.textRequired'));
|
||||
}
|
||||
try {
|
||||
await createQuestion({
|
||||
examId: exam.id,
|
||||
// The API requires Amharic; it falls back to the English text server-side
|
||||
// as well, but sending it explicitly keeps the request self-describing.
|
||||
title: { en: titleEn.trim(), am: titleAm.trim() || titleEn.trim() },
|
||||
form,
|
||||
points,
|
||||
options:
|
||||
form === 'CHOICE'
|
||||
? options.map((o) => ({
|
||||
text: { en: o.textEn.trim(), am: o.textAm.trim() || o.textEn.trim() },
|
||||
isCorrect: o.isCorrect,
|
||||
}))
|
||||
: undefined,
|
||||
}).unwrap();
|
||||
notify.success(t('exam.newQuestion.created'));
|
||||
close();
|
||||
} catch (error) {
|
||||
notify.error(describeExamQuestionError(t, extractErrorMessage(error, t('exam.error'))));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={close} title={t('exam.newQuestion.title')} size="lg" radius="lg">
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c="dimmed">{t('exam.newQuestion.hint')}</Text>
|
||||
<TextInput
|
||||
label={t('exam.newQuestion.titleEn')}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t('exam.newQuestion.titleAm')}
|
||||
value={titleAm}
|
||||
onChange={(e) => setTitleAm(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t('exam.newQuestion.form')}
|
||||
data={formOptions}
|
||||
value={form}
|
||||
onChange={(v) => setForm((v as QuestionForm) ?? null)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('exam.newQuestion.points')}
|
||||
value={points}
|
||||
onChange={(v) => setPoints(Number(v))}
|
||||
min={1}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
</Group>
|
||||
{form === 'CHOICE' && (
|
||||
<Stack gap="xs">
|
||||
<Text fz="sm" fw={500}>{t('exam.newQuestion.options')}</Text>
|
||||
{options.map((option, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
label={t('exam.newQuestion.optionEn', { number: index + 1 })}
|
||||
value={option.textEn}
|
||||
onChange={(e) => updateOption(index, { textEn: e.currentTarget.value })}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t('exam.newQuestion.optionAm', { number: index + 1 })}
|
||||
value={option.textAm}
|
||||
onChange={(e) => updateOption(index, { textAm: e.currentTarget.value })}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('exam.newQuestion.correct')}
|
||||
checked={option.isCorrect}
|
||||
onChange={() => updateOption(index, { isCorrect: !option.isCorrect })}
|
||||
mb={6}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
mb={8}
|
||||
disabled={options.length <= 2}
|
||||
onClick={() => setOptions((current) => current.filter((_, i) => i !== index))}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
w="fit-content"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => setOptions((current) => [...current, { textEn: '', textAm: '', isCorrect: false }])}
|
||||
>
|
||||
{t('exam.newQuestion.addOption')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
<ModalFooter>
|
||||
<Button variant="default" size="sm" onClick={close}>{t('exam.cancel')}</Button>
|
||||
<Button size="sm" loading={isLoading} onClick={submit}>{t('exam.newQuestion.create')}</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { IconCircleCheck, IconDownload, IconFileSpreadsheet, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { ModalFooter, notify } from '@ema-platform/ui';
|
||||
import { downloadAuthedFile, extractErrorMessage } from '@ema-platform/api';
|
||||
import { useImportExamQuestionsMutation } from '../../api/exam-api';
|
||||
import type { Exam, QuestionImportReport } from '../../types/exam';
|
||||
import { describeExamQuestionError, describeImportError } from './errors';
|
||||
|
||||
/**
|
||||
* "Import from Excel": upload → validate (a dry run on the server, which
|
||||
* reports every error at once) → preview → import. The real import runs the
|
||||
* same validation again and is all-or-nothing, so the preview the officer
|
||||
* confirmed is what lands on the paper, or nothing does.
|
||||
*/
|
||||
export function ExamQuestionImportModal({
|
||||
exam,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
exam: Exam;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [importQuestions, { isLoading }] = useImportExamQuestionsMutation();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [report, setReport] = useState<QuestionImportReport | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setFile(null);
|
||||
setReport(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const run = async (dryRun: boolean) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const outcome = await importQuestions({ examId: exam.id, file, dryRun }).unwrap();
|
||||
setReport(outcome);
|
||||
if (!dryRun && outcome.imported > 0) {
|
||||
notify.success(t('exam.import.imported', { count: outcome.imported }));
|
||||
close();
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(describeExamQuestionError(t, extractErrorMessage(error, t('exam.error'))));
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = async () => {
|
||||
try {
|
||||
await downloadAuthedFile('/exams/questions/import-template', 'exam-questions-template.xlsx');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const valid = report !== null && report.errors.length === 0 && report.rows.length > 0;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={close} title={t('exam.import.title')} size="xl" radius="lg">
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('exam.import.hint')}{' '}
|
||||
<Anchor fz="sm" onClick={downloadTemplate}>
|
||||
<IconDownload size={12} style={{ verticalAlign: 'middle' }} /> {t('exam.import.template')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
<FileInput
|
||||
label={t('exam.import.file')}
|
||||
placeholder="questions.xlsx"
|
||||
accept=".xlsx,.xlsm"
|
||||
leftSection={<IconFileSpreadsheet size={16} />}
|
||||
value={file}
|
||||
onChange={(next) => {
|
||||
setFile(next);
|
||||
setReport(null);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{report && report.errors.length > 0 && (
|
||||
<Alert color="red" icon={<IconInfoCircle size={16} />} title={t('exam.import.errors', { count: report.errors.length })}>
|
||||
<ScrollArea.Autosize mah={200}>
|
||||
<Table fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('exam.import.row')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.column')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.problem')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{report.errors.map((error, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>{error.row || '—'}</Table.Td>
|
||||
<Table.Td>{error.column ?? '—'}</Table.Td>
|
||||
<Table.Td>{describeImportError(t, error.message)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
<Text fz="xs" mt="xs">{t('exam.import.nothingImported')}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{report && report.rows.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Text fz="sm" fw={600}>{t('exam.import.preview', { count: report.rows.length })}</Text>
|
||||
{valid && (
|
||||
<Badge color="teal" variant="light" leftSection={<IconCircleCheck size={12} />}>
|
||||
{t('exam.import.valid')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={280}>
|
||||
<Table striped fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('exam.import.row')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.question')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.type')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.points')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.options')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{report.rows.map((row) => (
|
||||
<Table.Tr key={row.row}>
|
||||
<Table.Td>{row.row}</Table.Td>
|
||||
<Table.Td><Text fz="xs" lineClamp={2}>{row.titleEn}</Text></Table.Td>
|
||||
<Table.Td>{t(`exam.formType.${row.form}`)}</Table.Td>
|
||||
<Table.Td>{row.points}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.options.length
|
||||
? row.options.map((o) => (o.correct ? `${o.letter}✓` : o.letter)).join(' ')
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" size="sm" onClick={close}>{t('exam.cancel')}</Button>
|
||||
<Button variant="light" size="sm" disabled={!file} loading={isLoading && !valid} onClick={() => run(true)}>
|
||||
{t('exam.import.validate')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={!valid} loading={isLoading && valid} onClick={() => run(false)}>
|
||||
{t('exam.import.import')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconSearch } from '@tabler/icons-react';
|
||||
import { ModalFooter, notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useGetQuestionsQuery } from '../../../question/api/question-api';
|
||||
import { useAddExamQuestionsMutation } from '../../api/exam-api';
|
||||
import type { Exam } from '../../types/exam';
|
||||
import { describeExamQuestionError } from './errors';
|
||||
|
||||
/**
|
||||
* "Create question from question bank": pick approved items for this exam's
|
||||
* subject and add them to the paper as it stands. Items already on the paper
|
||||
* are not offered — the bank is reusable, the paper holds each item once.
|
||||
*/
|
||||
export function QuestionBankPickerModal({
|
||||
exam,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
exam: Exam;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: qRes, isFetching } = useGetQuestionsQuery(undefined, { skip: !opened });
|
||||
const [addQuestions, { isLoading }] = useAddExamQuestionsMutation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const onPaper = useMemo(
|
||||
() => new Set((exam.questions ?? []).map((q) => q.id)),
|
||||
[exam.questions],
|
||||
);
|
||||
|
||||
// Only approved bank items of this subject can go on a paper (US-EXAM-003),
|
||||
// and the form has to fit the session unless it is a mixed (BOTH) paper.
|
||||
const candidates = useMemo(
|
||||
() =>
|
||||
(qRes?.items ?? []).filter(
|
||||
(q) =>
|
||||
q.certificationId === exam.certificationId &&
|
||||
q.status === 'APPROVED' &&
|
||||
q.isActive &&
|
||||
(exam.form === 'BOTH' || q.form === exam.form) &&
|
||||
!onPaper.has(q.id),
|
||||
),
|
||||
[qRes, exam.certificationId, exam.form, onPaper],
|
||||
);
|
||||
|
||||
const visible = search
|
||||
? candidates.filter((q) =>
|
||||
`${q.title.en ?? ''} ${q.title.am ?? ''}`.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: candidates;
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
setSelected(new Set());
|
||||
setSearch('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const add = async () => {
|
||||
try {
|
||||
await addQuestions({ examId: exam.id, questionIds: [...selected] }).unwrap();
|
||||
notify.success(t('exam.bank.added', { count: selected.size }));
|
||||
close();
|
||||
} catch (error) {
|
||||
notify.error(describeExamQuestionError(t, extractErrorMessage(error, t('exam.error'))));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={close} title={t('exam.bank.title')} size="lg" radius="lg">
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c="dimmed">{t('exam.bank.hint')}</Text>
|
||||
<TextInput
|
||||
placeholder={t('exam.bank.search')}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
{!isFetching && candidates.length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>{t('exam.bank.empty')}</Alert>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={360}>
|
||||
<Stack gap={6}>
|
||||
{visible.map((q) => (
|
||||
<Checkbox
|
||||
key={q.id}
|
||||
checked={selected.has(q.id)}
|
||||
onChange={() => toggle(q.id)}
|
||||
label={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fz="sm" lineClamp={2}>{q.title[locale] || q.title.en}</Text>
|
||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||
{t(`exam.formType.${q.form}`)}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
<ModalFooter>
|
||||
<Button variant="default" size="sm" onClick={close}>{t('exam.cancel')}</Button>
|
||||
<Button size="sm" loading={isLoading} disabled={selected.size === 0} onClick={add}>
|
||||
{t('exam.bank.add', { count: selected.size })}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
/**
|
||||
* Error keys the question-management endpoints return, made readable. Keys
|
||||
* carry detail after a colon (`paper_cannot_reach_cutting_point:20/50`), so
|
||||
* the prefix is matched and the detail passed to the message.
|
||||
*/
|
||||
export function describeExamQuestionError(t: TFunction, key: string): string {
|
||||
const [code, detail = ''] = key.split(':');
|
||||
switch (code) {
|
||||
case 'paper_locked_after_registration':
|
||||
return t('exam.paperLocked');
|
||||
case 'paper_cannot_reach_cutting_point': {
|
||||
const [max, cuttingPoint] = detail.split('/');
|
||||
return t('exam.cannotReachCuttingPoint', { max, cuttingPoint });
|
||||
}
|
||||
case 'question_not_approved':
|
||||
return t('question.qc.onlyApprovedUsable');
|
||||
case 'question_subject_mismatch':
|
||||
return t('exam.questionErrors.subjectMismatch');
|
||||
case 'question_not_found':
|
||||
return t('exam.questionErrors.notFound');
|
||||
case 'options_required':
|
||||
return t('exam.newQuestion.needTwo');
|
||||
case 'at_least_one_correct_option_required':
|
||||
return t('exam.newQuestion.needCorrect');
|
||||
case 'invalid_points':
|
||||
return t('exam.newQuestion.fillRequired');
|
||||
case 'excel_file_required':
|
||||
case 'file_required':
|
||||
return t('exam.import.errorKeys.invalid_excel_file');
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
/** Row-level import problems, as the validator names them. */
|
||||
export function describeImportError(t: TFunction, message: string): string {
|
||||
const [code, detail = ''] = message.split(':');
|
||||
const known = [
|
||||
'question_text_required',
|
||||
'invalid_question_type',
|
||||
'invalid_points',
|
||||
'options_required',
|
||||
'correct_answer_required',
|
||||
'correct_answer_invalid',
|
||||
'duplicate_in_file',
|
||||
'duplicate_in_bank',
|
||||
'missing_columns',
|
||||
'too_many_rows',
|
||||
'no_questions_in_file',
|
||||
'invalid_excel_file',
|
||||
];
|
||||
if (code === 'paper_cannot_reach_cutting_point') {
|
||||
const [max, cuttingPoint] = detail.split('/');
|
||||
return t('exam.cannotReachCuttingPoint', { max, cuttingPoint });
|
||||
}
|
||||
if (known.includes(code)) return t(`exam.import.errorKeys.${code}`, { detail });
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { QuestionBankPickerModal } from './QuestionBankPickerModal';
|
||||
export { ExamQuestionCreateModal } from './ExamQuestionCreateModal';
|
||||
export { ExamQuestionImportModal } from './ExamQuestionImportModal';
|
||||
export { describeExamQuestionError } from './errors';
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, Paper, SimpleGrid, Text, Title, Badge } from '@mantine/core';
|
||||
import { IconHourglass } from '@tabler/icons-react';
|
||||
import { useGetExamWaitMetricsQuery } from '../api/exam-api';
|
||||
import type { WaitStat } from '../types/exam';
|
||||
|
||||
function minutes(value: number | null, t: (key: string, options?: Record<string, unknown>) => string): string {
|
||||
if (value === null) return '—';
|
||||
const abs = Math.abs(value);
|
||||
const label =
|
||||
abs >= 1440
|
||||
? t('exam.metrics.days', { value: Math.round((abs / 1440) * 10) / 10 })
|
||||
: abs >= 60
|
||||
? t('exam.metrics.hours', { value: Math.round((abs / 60) * 10) / 10 })
|
||||
: t('exam.metrics.minutes', { value: Math.round(abs * 10) / 10 });
|
||||
return value < 0 ? `−${label}` : label;
|
||||
}
|
||||
|
||||
function StatCard({ label, stat, t }: { label: string; stat: WaitStat; t: (key: string, options?: Record<string, unknown>) => string }) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz={22} fw={700} mt={4}>{minutes(stat.averageMinutes, t)}</Text>
|
||||
<Text fz="xs" c="dimmed">{t('exam.metrics.average')}</Text>
|
||||
<Group gap="md" mt="xs">
|
||||
<Text fz="xs">{t('exam.metrics.min')}: <b>{minutes(stat.minMinutes, t)}</b></Text>
|
||||
<Text fz="xs">{t('exam.metrics.max')}: <b>{minutes(stat.maxMinutes, t)}</b></Text>
|
||||
<Text fz="xs">{t('exam.metrics.count')}: <b>{stat.count}</b></Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exam wait metrics — how long candidates waited at each step up to the
|
||||
* sitting, read off the registration, attendance and attempt timestamps
|
||||
* the workflow already writes. Analytics only: nothing here can change a
|
||||
* registration, an attendance ruling, a result or a certificate.
|
||||
*/
|
||||
export function ExamWaitMetricsPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isError } = useGetExamWaitMetricsQuery(examId);
|
||||
if (isError || !data) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<IconHourglass size={18} />
|
||||
<Title order={5}>{t('exam.metrics.section')}</Title>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="gray">{t('exam.metrics.candidates', { count: data.candidateCount })}</Badge>
|
||||
<Badge variant="light" color="teal">{t('exam.metrics.attended', { count: data.attendedCount })}</Badge>
|
||||
<Badge variant="light" color="blue">{t('exam.metrics.started', { count: data.startedCount })}</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" mb="md">{t('exam.metrics.hint')}</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
<StatCard label={t('exam.metrics.registrationToScheduled')} stat={data.registrationToScheduled} t={t} />
|
||||
<StatCard label={t('exam.metrics.scheduledToAttendance')} stat={data.scheduledToAttendance} t={t} />
|
||||
<StatCard label={t('exam.metrics.attendanceToExamStart')} stat={data.attendanceToExamStart} t={t} />
|
||||
<StatCard label={t('exam.metrics.scheduledToExamStart')} stat={data.scheduledToExamStart} t={t} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ThemeIcon,
|
||||
Box,
|
||||
Tooltip,
|
||||
Menu,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
@@ -40,6 +41,11 @@ import {
|
||||
IconUser,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconFileSpreadsheet,
|
||||
IconPencilPlus,
|
||||
IconListCheck,
|
||||
IconChevronDown,
|
||||
} from '@tabler/icons-react';
|
||||
import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
@@ -57,6 +63,12 @@ import { QuestionAssigner } from '../components/QuestionAssigner';
|
||||
import { RecordResultModal } from '../../result/components/RecordResultModal';
|
||||
import { ExamCandidatesPanel } from '../components/ExamCandidatesPanel';
|
||||
import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
||||
import { ExamWaitMetricsPanel } from '../components/ExamWaitMetricsPanel';
|
||||
import {
|
||||
ExamQuestionCreateModal,
|
||||
ExamQuestionImportModal,
|
||||
QuestionBankPickerModal,
|
||||
} from '../components/ExamQuestionActions';
|
||||
import { PageLoader } from '@ema-platform/ui';
|
||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
@@ -66,7 +78,6 @@ const STATUS_TONE: Record<string, StatusTone> = {
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
POSTPONED: 'pending',
|
||||
PUBLISHED: 'success',
|
||||
};
|
||||
|
||||
const FORM_LABEL: Record<string, string> = {
|
||||
@@ -109,6 +120,11 @@ export function ExamDetailPage() {
|
||||
useDisclosure(false);
|
||||
const [assignOpened, { open: openAssign, close: closeAssign }] =
|
||||
useDisclosure(false);
|
||||
// The three contextual ways to populate this exam's paper without leaving it.
|
||||
const [bankOpened, { open: openBank, close: closeBank }] = useDisclosure(false);
|
||||
const [importOpened, { open: openImport, close: closeImport }] = useDisclosure(false);
|
||||
const [newQuestionOpened, { open: openNewQuestion, close: closeNewQuestion }] =
|
||||
useDisclosure(false);
|
||||
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
|
||||
const [randomCount, setRandomCount] = useState(5);
|
||||
const [updateExam] = useUpdateExamMutation();
|
||||
@@ -415,6 +431,14 @@ export function ExamDetailPage() {
|
||||
/>
|
||||
<InfoRow label={t("exam.detail.venue")} value={exam.venue} />
|
||||
<InfoRow label={t("exam.detail.date")} value={exam.date} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.window")}
|
||||
value={
|
||||
exam.startTime || exam.endTime
|
||||
? `${exam.startTime?.slice(0, 5) ?? "00:00"} – ${exam.endTime?.slice(0, 5) ?? "23:59"}`
|
||||
: t("exam.detail.allDay")
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.administration")}
|
||||
value={t(`exam.admin.${exam.administrationMethod}`)}
|
||||
@@ -485,15 +509,37 @@ export function ExamDetailPage() {
|
||||
{/* 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>
|
||||
{/* One place to add questions from this exam's own page:
|
||||
the bank, an Excel sheet, or a brand-new item — every
|
||||
path lands the question on this paper. */}
|
||||
<Menu shadow="md" width={240} position="bottom-end" disabled={paperLocked}>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
disabled={paperLocked}
|
||||
>
|
||||
{t("exam.questionsMenu.add")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconDatabase size={14} />} onClick={openBank}>
|
||||
{t("exam.questionsMenu.fromBank")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconFileSpreadsheet size={14} />} onClick={openImport}>
|
||||
{t("exam.questionsMenu.importExcel")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPencilPlus size={14} />} onClick={openNewQuestion}>
|
||||
{t("exam.questionsMenu.fromScratch")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconListCheck size={14} />} onClick={openAssignModal}>
|
||||
{t("exam.questionsMenu.managePaper")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
@@ -539,8 +585,13 @@ export function ExamDetailPage() {
|
||||
{/* Exam-day operations: who sat the paper, and what went wrong */}
|
||||
<ExamCandidatesPanel examId={exam.id} />
|
||||
<ExamIncidentsPanel examId={exam.id} />
|
||||
{/* Analytics over the same records — read-only, never a step in the workflow */}
|
||||
<ExamWaitMetricsPanel examId={exam.id} />
|
||||
|
||||
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
|
||||
<QuestionBankPickerModal exam={exam} opened={bankOpened} onClose={closeBank} />
|
||||
<ExamQuestionImportModal exam={exam} opened={importOpened} onClose={closeImport} />
|
||||
<ExamQuestionCreateModal exam={exam} opened={newQuestionOpened} onClose={closeNewQuestion} />
|
||||
|
||||
{/* Question assignment modal */}
|
||||
<Modal
|
||||
|
||||
@@ -11,7 +11,6 @@ const STATUS_TONE: Record<string, StatusTone> = {
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
POSTPONED: 'pending',
|
||||
PUBLISHED: 'success',
|
||||
};
|
||||
|
||||
export function examColumns(
|
||||
@@ -41,7 +40,18 @@ export function examColumns(
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.date"),
|
||||
cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>,
|
||||
cell: ({ row }) => {
|
||||
const start = row.original.startTime?.slice(0, 5);
|
||||
const end = row.original.endTime?.slice(0, 5);
|
||||
return (
|
||||
<Text fz="sm">
|
||||
{row.original.date}
|
||||
{start || end ? (
|
||||
<Text span fz="xs" c="dimmed">{` · ${start ?? "00:00"} – ${end ?? "23:59"}`}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.type"),
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||
import { useGetRanksQuery, useLocalized } from "@ema-platform/api";
|
||||
import { extractErrorMessage, useGetRanksQuery, useLocalized } from "@ema-platform/api";
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
useCreateExamMutation,
|
||||
@@ -78,6 +78,8 @@ function ExamForm({
|
||||
const [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? "");
|
||||
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? "");
|
||||
const [date, setDate] = useState(editing?.date ?? "");
|
||||
const [startTime, setStartTime] = useState(editing?.startTime?.slice(0, 5) ?? "");
|
||||
const [endTime, setEndTime] = useState(editing?.endTime?.slice(0, 5) ?? "");
|
||||
const [days, setDays] = useState(editing?.givenTime?.days ?? 0);
|
||||
const [hours, setHours] = useState(editing?.givenTime?.hours ?? 0);
|
||||
const [minutes, setMinutes] = useState(editing?.givenTime?.minutes ?? 0);
|
||||
@@ -113,6 +115,8 @@ function ExamForm({
|
||||
directionEn,
|
||||
directionAm,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
venue,
|
||||
type,
|
||||
form,
|
||||
@@ -150,6 +154,8 @@ function ExamForm({
|
||||
directionEn,
|
||||
directionAm,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
@@ -239,6 +245,26 @@ function ExamForm({
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
{/* The session window. The backend refuses to start an attempt
|
||||
before startTime (exam_not_started) and after endTime, on its
|
||||
own clock — this is only where the officer sets it. */}
|
||||
<Group gap="sm" grow>
|
||||
<TextInput
|
||||
label={t("exam.form.startTime")}
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.endTime")}
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{t("exam.form.windowHint")}</Text>
|
||||
<TextInput
|
||||
label={t("exam.form.venue")}
|
||||
placeholder={t("exam.form.venuePlaceholder")}
|
||||
@@ -375,7 +401,6 @@ function ExamForm({
|
||||
{ value: "COMPLETED", label: t("exam.form.completed") },
|
||||
{ value: "CANCELLED", label: t("exam.form.cancelled") },
|
||||
{ value: "POSTPONED", label: t("exam.form.postponed") },
|
||||
{ value: "PUBLISHED", label: t("exam.form.published") },
|
||||
]}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
@@ -408,6 +433,14 @@ function ExamForm({
|
||||
<ReviewRow label={t("exam.form.titleEn")} value={titleEn} />
|
||||
<ReviewRow label={t("exam.form.titleAm")} value={titleAm} />
|
||||
<ReviewRow label={t("exam.form.examDate")} value={date} />
|
||||
<ReviewRow
|
||||
label={t("exam.detail.window")}
|
||||
value={
|
||||
startTime || endTime
|
||||
? `${startTime || "00:00"} – ${endTime || "23:59"}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ReviewRow label={t("exam.form.venue")} value={venue} />
|
||||
<ReviewRow
|
||||
label={t("exam.detail.timeAllowed")}
|
||||
@@ -522,6 +555,17 @@ export function ExamPage() {
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const describeError = (error: unknown) => {
|
||||
const key = extractErrorMessage(error, "");
|
||||
if (key === "exam_window_invalid") return notify.error(t("exam.form.windowInvalid"));
|
||||
if (key.startsWith("exam_scoring_locked_by_results")) {
|
||||
return notify.error(
|
||||
t("exam.errors.scoringLocked", { count: Number(key.split(":")[1] ?? 0) }),
|
||||
);
|
||||
}
|
||||
return handleError(error);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any, isEdit: boolean) => {
|
||||
const payload: any = {
|
||||
certificationId: values.certificationId,
|
||||
@@ -531,6 +575,8 @@ export function ExamPage() {
|
||||
? { en: values.directionEn, am: values.directionAm }
|
||||
: undefined,
|
||||
date: values.date,
|
||||
startTime: values.startTime || null,
|
||||
endTime: values.endTime || null,
|
||||
givenTime: {
|
||||
days: values.days,
|
||||
hours: values.hours,
|
||||
@@ -556,7 +602,7 @@ export function ExamPage() {
|
||||
}
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
describeError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -702,7 +748,6 @@ export function ExamPage() {
|
||||
{ value: "COMPLETED", label: t("exam.form.completed") },
|
||||
{ value: "CANCELLED", label: t("exam.form.cancelled") },
|
||||
{ value: "POSTPONED", label: t("exam.form.postponed") },
|
||||
{ value: "PUBLISHED", label: t("exam.form.published") },
|
||||
]}
|
||||
value={pendingStatus}
|
||||
onChange={(value) => setPendingStatus(value as Exam["status"])}
|
||||
|
||||
@@ -16,6 +16,8 @@ const complete: ExamFormValues = {
|
||||
directionEn: '',
|
||||
directionAm: '',
|
||||
date: '2026-09-10',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
venue: 'Addis Ababa',
|
||||
type: 'WRITTEN',
|
||||
form: 'CHOICE',
|
||||
@@ -86,3 +88,33 @@ describe('exam form step validation', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The session window is optional on both ends, but when both are given the
|
||||
* exam cannot close before it opens — the same rule the API enforces as
|
||||
* exam_window_invalid, reported here on the step that owns the fields.
|
||||
*/
|
||||
describe('session window', () => {
|
||||
it('accepts no window, a start alone, an end alone, and a well-ordered pair', () => {
|
||||
expect(validateBasic(complete)).toBeNull();
|
||||
expect(validateBasic({ ...complete, startTime: '10:00' })).toBeNull();
|
||||
expect(validateBasic({ ...complete, endTime: '12:00' })).toBeNull();
|
||||
expect(validateBasic({ ...complete, startTime: '10:00', endTime: '12:00' })).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses an end at or before the start', () => {
|
||||
expect(validateBasic({ ...complete, startTime: '12:00', endTime: '10:00' })).toBe(
|
||||
'exam.form.windowInvalid',
|
||||
);
|
||||
expect(validateBasic({ ...complete, startTime: '10:00', endTime: '10:00' })).toBe(
|
||||
'exam.form.windowInvalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('lands the user back on Basic Info to fix it', () => {
|
||||
expect(stepOfError('exam.form.windowInvalid')).toBe(0);
|
||||
expect(validateAll({ ...complete, startTime: '12:00', endTime: '10:00' })).toBe(
|
||||
'exam.form.windowInvalid',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface ExamFormValues {
|
||||
directionEn: string;
|
||||
directionAm: string;
|
||||
date: string;
|
||||
/** `HH:MM` or empty — the session window, optional on both ends. */
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
venue: string;
|
||||
type: string | null;
|
||||
form: string | null;
|
||||
@@ -32,6 +35,12 @@ export function validateBasic(v: ExamFormValues): string | null {
|
||||
if ((v.directionEn || v.directionAm) && !(v.directionEn && v.directionAm)) {
|
||||
return 'exam.form.directionBothLanguages';
|
||||
}
|
||||
// A session cannot close before it opens. Same rule the API applies
|
||||
// (exam_window_invalid), caught here so it is reported on the step that
|
||||
// owns the fields.
|
||||
if (v.startTime && v.endTime && v.endTime <= v.startTime) {
|
||||
return 'exam.form.windowInvalid';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,13 @@ export type ExamType = "WRITTEN" | "ORAL";
|
||||
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
|
||||
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
|
||||
export type ExamSelectionMethod = "MANUAL" | "RANDOM";
|
||||
/**
|
||||
* The session's own lifecycle. No PUBLISHED: whether a candidate can see
|
||||
* their mark is that candidate's Result (`reviewStatus`/`publishedAt`), never
|
||||
* a property of the exam every other candidate shares. Mirrors EExamStatus.
|
||||
*/
|
||||
export type ExamStatus =
|
||||
"PENDING" | "ACTIVE" | "COMPLETED" | "CANCELLED" | "POSTPONED" | "PUBLISHED";
|
||||
"PENDING" | "ACTIVE" | "COMPLETED" | "CANCELLED" | "POSTPONED";
|
||||
|
||||
/** Only populated when the exam is fetched with `?i=questions,questions.options`. */
|
||||
export interface QuestionOptionBrief {
|
||||
@@ -39,6 +44,10 @@ export interface Exam {
|
||||
title: LocalePair;
|
||||
direction: LocalePair | null;
|
||||
date: string;
|
||||
/** `HH:MM[:SS]` on `date`, authority timezone; null means the session opens at the start of the day. */
|
||||
startTime: string | null;
|
||||
/** `HH:MM[:SS]` on `date`; null means the end of the day. Only bounds *starting* an attempt. */
|
||||
endTime: string | null;
|
||||
givenTime: EstimatedTime | null;
|
||||
type: ExamType;
|
||||
form: ExamForm;
|
||||
@@ -63,6 +72,8 @@ export interface CreateExamPayload {
|
||||
title: LocalePair;
|
||||
direction?: LocalePair;
|
||||
date: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
givenTime: EstimatedTime;
|
||||
type: ExamType;
|
||||
form: ExamForm;
|
||||
@@ -79,6 +90,8 @@ export interface UpdateExamPayload {
|
||||
title?: LocalePair;
|
||||
direction?: LocalePair;
|
||||
date?: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
givenTime?: EstimatedTime;
|
||||
type?: ExamType;
|
||||
form?: ExamForm;
|
||||
@@ -128,8 +141,38 @@ export interface ExamRegistration {
|
||||
id: string;
|
||||
status: "IN_PROGRESS" | "SUBMITTED" | "EXPIRED";
|
||||
} | null;
|
||||
/**
|
||||
* Where the sitting stands, derived server-side from this row, the attempt
|
||||
* and the published mark — the same reading the COC queue/detail and the
|
||||
* applicant's portal show.
|
||||
*/
|
||||
examState?: RegistrationExamState;
|
||||
/**
|
||||
* The mark already on file for this candidate, at any review stage. Null
|
||||
* until someone (or the engine) has marked the paper — which is what
|
||||
* decides whether the marking screen may still offer this candidate.
|
||||
*/
|
||||
result?: {
|
||||
id: string;
|
||||
status: "PASSED" | "FAILED";
|
||||
reviewStatus: "MARKED" | "MODERATED" | "APPROVED" | "PUBLISHED" | "RETURNED";
|
||||
autoGraded: boolean;
|
||||
totalScore: number;
|
||||
publishedAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Mirrors the server's ExamState (ExamStateService / resolveExamState). */
|
||||
export type RegistrationExamState =
|
||||
| "NOT_REGISTERED"
|
||||
| "REGISTERED"
|
||||
| "ATTENDANCE_CONFIRMED"
|
||||
| "NOT_SITTING"
|
||||
| "IN_PROGRESS"
|
||||
| "UNDER_EVALUATION"
|
||||
| "PASSED"
|
||||
| "FAILED";
|
||||
|
||||
export type RegradeOutcome =
|
||||
{ graded: true; resultId: string } | { graded: false; reason: string };
|
||||
|
||||
@@ -189,3 +232,83 @@ export interface ResolveIncidentPayload {
|
||||
outcome: "RESOLVED" | "DISMISSED";
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
/** Bank items appended to a paper without replacing what is already on it. */
|
||||
export interface AddExamQuestionsPayload {
|
||||
examId: string;
|
||||
questionIds: string[];
|
||||
}
|
||||
|
||||
export interface ExamQuestionOptionInput {
|
||||
text: LocalePair;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
/** A question authored straight onto one exam ("add new question from scratch"). */
|
||||
export interface CreateExamQuestionPayload {
|
||||
examId: string;
|
||||
title: LocalePair;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
time?: EstimatedTime;
|
||||
options?: ExamQuestionOptionInput[];
|
||||
}
|
||||
|
||||
export interface ImportedQuestionOption {
|
||||
letter: string;
|
||||
en: string;
|
||||
am: string | null;
|
||||
correct: boolean;
|
||||
}
|
||||
|
||||
export interface ImportedQuestionRow {
|
||||
/** 1-based sheet row, as the officer sees it in Excel. */
|
||||
row: number;
|
||||
titleEn: string;
|
||||
titleAm: string | null;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
options: ImportedQuestionOption[];
|
||||
}
|
||||
|
||||
export interface QuestionImportError {
|
||||
/** 0 for a file-level problem (missing headers, empty sheet). */
|
||||
row: number;
|
||||
column?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** What one Excel upload came to — the preview, or the errors that stopped it. */
|
||||
export interface QuestionImportReport {
|
||||
rows: ImportedQuestionRow[];
|
||||
errors: QuestionImportError[];
|
||||
imported: number;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export interface ImportExamQuestionsPayload {
|
||||
examId: string;
|
||||
file: File;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
/** Summary statistics over one wait interval, in minutes. */
|
||||
export interface WaitStat {
|
||||
count: number;
|
||||
averageMinutes: number | null;
|
||||
minMinutes: number | null;
|
||||
maxMinutes: number | null;
|
||||
}
|
||||
|
||||
/** Candidate waiting/scheduling delays for one session — read-only analytics. */
|
||||
export interface ExamWaitMetrics {
|
||||
examId: string;
|
||||
scheduledStart: string;
|
||||
candidateCount: number;
|
||||
attendedCount: number;
|
||||
startedCount: number;
|
||||
registrationToScheduled: WaitStat;
|
||||
scheduledToAttendance: WaitStat;
|
||||
attendanceToExamStart: WaitStat;
|
||||
scheduledToExamStart: WaitStat;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
applicantOrCompanyName,
|
||||
localized,
|
||||
type ApplicationKind,
|
||||
type ExamState,
|
||||
type LicenseApplication,
|
||||
type QueueFilter,
|
||||
} from "@ema-platform/api";
|
||||
@@ -16,6 +17,18 @@ const KIND_COLOR: Record<ApplicationKind, string> = {
|
||||
RENEWAL: "teal",
|
||||
REISSUE: "orange",
|
||||
};
|
||||
|
||||
/** Same palette as the COC detail's Examination panel. */
|
||||
const EXAM_STATE_COLORS: Record<ExamState, string> = {
|
||||
NOT_REGISTERED: "gray",
|
||||
REGISTERED: "cyan",
|
||||
ATTENDANCE_CONFIRMED: "indigo",
|
||||
NOT_SITTING: "orange",
|
||||
IN_PROGRESS: "blue",
|
||||
UNDER_EVALUATION: "yellow",
|
||||
PASSED: "teal",
|
||||
FAILED: "red",
|
||||
};
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { dateDisplayer } from "@ema-platform/shared";
|
||||
import { computeSla } from "../../sla";
|
||||
@@ -142,6 +155,23 @@ export function licenseQueueColumns(
|
||||
`queue.statusValues.${row.original.status}`,
|
||||
STATUS_LABELS[row.original.status],
|
||||
);
|
||||
// The exam leg is shown as the sitting actually stands — registered,
|
||||
// present, sat, passed — derived server-side from the registration,
|
||||
// the attempt and the published mark, the same reading the COC detail
|
||||
// and the applicant's portal use. The application status alone
|
||||
// lagged behind a published result, which is how the queue kept
|
||||
// saying "exam scheduled" over a pass.
|
||||
const examState = row.original.examState;
|
||||
if (examState) {
|
||||
const examLabel = t(`review.exam.state.${examState}`, examState);
|
||||
return (
|
||||
<Tooltip label={`${examLabel} · ${label}`} withArrow>
|
||||
<Badge color={EXAM_STATE_COLORS[examState]} variant="light">
|
||||
{examLabel}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
||||
|
||||
@@ -61,6 +61,16 @@ const resultApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/**
|
||||
* Applicant-level publication: only this candidate's mark, notification
|
||||
* and application are affected. The exam and every other candidate stay
|
||||
* exactly as they were.
|
||||
*/
|
||||
publishResult: builder.mutation<Result, string>({
|
||||
query: (id) => ({ url: `/results/${id}/publish`, method: 'POST' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Every approved result for a session at once — a convenience over publishResult. */
|
||||
publishExamResults: builder.mutation<
|
||||
{ examId: string; published: number; skipped: number },
|
||||
string
|
||||
@@ -98,6 +108,7 @@ export const {
|
||||
useModerateResultMutation,
|
||||
useApproveResultMutation,
|
||||
useReturnResultMutation,
|
||||
usePublishResultMutation,
|
||||
usePublishExamResultsMutation,
|
||||
useGetPendingAppealsQuery,
|
||||
useDecideAppealMutation,
|
||||
|
||||
@@ -7,9 +7,9 @@ export function recordResultColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
handlers: {
|
||||
scores: Record<string, number>;
|
||||
scores: Record<string, number | ''>;
|
||||
questionRemarks: Record<string, string>;
|
||||
onScoreChange: (questionId: string, value: number) => void;
|
||||
onScoreChange: (questionId: string, value: number | '') => 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). */
|
||||
@@ -54,8 +54,17 @@ export function recordResultColumns(
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<NumberInput
|
||||
value={handlers.scores[row.original.id] ?? 0}
|
||||
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
|
||||
// Empty until the examiner types a mark: an untouched box is not
|
||||
// a zero, and the form will not save while one is left empty.
|
||||
value={handlers.scores[row.original.id] ?? ''}
|
||||
onChange={(v) =>
|
||||
handlers.onScoreChange(
|
||||
row.original.id,
|
||||
v === '' || v === null || v === undefined ? '' : Number(v),
|
||||
)
|
||||
}
|
||||
placeholder={t('result.recordModal.scorePlaceholder')}
|
||||
error={handlers.scores[row.original.id] === '' || handlers.scores[row.original.id] === undefined}
|
||||
min={0}
|
||||
max={row.original.points}
|
||||
size="xs"
|
||||
|
||||
@@ -45,7 +45,10 @@ export function RecordResultModal({
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const [seafarerSearch, setSeafarerSearch] = useState('');
|
||||
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
|
||||
const [scores, setScores] = useState<Record<string, number>>({});
|
||||
// A score is either a number the examiner typed or not yet entered. It is
|
||||
// never defaulted to 0: an untouched box must not become a mark of zero,
|
||||
// and a paper with a box left empty must not be saved at all.
|
||||
const [scores, setScores] = useState<Record<string, number | ''>>({});
|
||||
const [questionRemarks, setQuestionRemarks] = useState<Record<string, string>>({});
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
@@ -78,7 +81,7 @@ export function RecordResultModal({
|
||||
// only ever fills in blanks, never stomps a manual edit already made.
|
||||
useEffect(() => {
|
||||
if (!gradingSheet) return;
|
||||
const autoScores: Record<string, number> = {};
|
||||
const autoScores: Record<string, number | ''> = {};
|
||||
for (const q of gradingSheet.questions) {
|
||||
if (q.autoScore !== null) autoScores[q.questionId] = q.autoScore;
|
||||
}
|
||||
@@ -88,10 +91,17 @@ export function RecordResultModal({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gradingSheet]);
|
||||
|
||||
const seafarerOptions = (registrations ?? [])
|
||||
.filter((registration) =>
|
||||
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
|
||||
)
|
||||
// Only candidates who sat the paper *and have no mark yet*. A candidate the
|
||||
// exam engine has already graded — or an examiner has already marked — is
|
||||
// not offered: the API refuses a second result (result_already_recorded),
|
||||
// and an engine-produced mark is locked in any case. Nobody is asked to
|
||||
// hand-record a result the system already holds.
|
||||
const sat = (registrations ?? []).filter((registration) =>
|
||||
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
|
||||
);
|
||||
const alreadyMarked = sat.filter((registration) => registration.result).length;
|
||||
const seafarerOptions = sat
|
||||
.filter((registration) => !registration.result)
|
||||
.map((registration) => ({
|
||||
value: registration.profileId,
|
||||
label: `${registration.admissionNumber} — ${[
|
||||
@@ -107,7 +117,8 @@ export function RecordResultModal({
|
||||
? seafarerOptions.filter((o: any) => o.label.toLowerCase().includes(seafarerSearch.toLowerCase()))
|
||||
: seafarerOptions;
|
||||
|
||||
const totalScore = questions.reduce((sum, q) => sum + (scores[q.id] ?? 0), 0);
|
||||
const unscored = questions.filter((q) => scores[q.id] === '' || scores[q.id] === undefined);
|
||||
const totalScore = questions.reduce((sum, q) => sum + (Number(scores[q.id]) || 0), 0);
|
||||
const maxScore = questions.reduce((sum, q) => sum + (q.points ?? 0), 0);
|
||||
// The cutting point is read per the exam's configured evaluation method —
|
||||
// an AVERAGE or PERCENTAGE exam must not be graded as a raw sum.
|
||||
@@ -123,7 +134,7 @@ export function RecordResultModal({
|
||||
: totalScore;
|
||||
const passed = effectiveScore >= exam.cuttingPoint;
|
||||
|
||||
const handleScoreChange = (questionId: string, value: number) => {
|
||||
const handleScoreChange = (questionId: string, value: number | '') => {
|
||||
setScores((prev) => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
@@ -136,21 +147,34 @@ export function RecordResultModal({
|
||||
notify.error(t('result.recordModal.seafarerRequired'));
|
||||
return;
|
||||
}
|
||||
// The same rules the API enforces (result_score_required,
|
||||
// result_remark_required), caught here so the officer is told which box
|
||||
// is empty before the request goes out. An empty mark is refused, never
|
||||
// scored as zero — and never turned into a pass.
|
||||
if (unscored.length) {
|
||||
notify.error(t('result.recordModal.scoresRequired', { count: unscored.length }));
|
||||
return;
|
||||
}
|
||||
if (!remark.trim()) {
|
||||
notify.error(t('result.recordModal.reasonRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const breakdowns = questions.map((q) => ({
|
||||
questionId: q.id,
|
||||
score: scores[q.id] ?? 0,
|
||||
score: Number(scores[q.id]),
|
||||
remark: questionRemarks[q.id] ?? '',
|
||||
}));
|
||||
// The outcome is not sent: the API derives PASSED/FAILED from the
|
||||
// session's evaluation method and cutting point, and stamps the
|
||||
// examiner on the row (US-EXAM-011). The preview below shows what that
|
||||
// computation will produce.
|
||||
// The outcome is not typed in: the API derives PASSED/FAILED from the
|
||||
// session's evaluation method and cutting point over the scores just
|
||||
// entered, and stamps the examiner on the row (US-EXAM-011). The
|
||||
// preview below shows exactly what that computation will produce, so
|
||||
// the officer is confirming an explicit outcome, not guessing one.
|
||||
await createResult({
|
||||
seafarerId: selectedSeafarerId,
|
||||
examId: exam.id,
|
||||
resultBreakdowns: breakdowns,
|
||||
remark: remark ? { en: remark, am: '' } : undefined,
|
||||
remark: { en: remark.trim(), am: '' },
|
||||
}).unwrap();
|
||||
notify.success(t('result.recordModal.saveSuccess'));
|
||||
setSelectedSeafarerId(null);
|
||||
@@ -161,14 +185,21 @@ export function RecordResultModal({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('result.recordModal.saveError'));
|
||||
const [code] = key.split(':');
|
||||
notify.error(
|
||||
key === 'candidate_not_registered'
|
||||
? 'This candidate is not registered for the session.'
|
||||
: key.startsWith('candidate_not_present')
|
||||
? `No paper to mark — the register says ${key.split(':')[1] ?? ''}.`
|
||||
: key === 'result_already_recorded'
|
||||
? 'A result has already been recorded for this candidate.'
|
||||
: key,
|
||||
code === 'candidate_not_registered'
|
||||
? t('result.recordModal.errors.notRegistered')
|
||||
: code === 'candidate_not_present'
|
||||
? t('result.recordModal.errors.notPresent', { ruling: key.split(':')[1] ?? '' })
|
||||
: code === 'result_already_recorded'
|
||||
? t('result.recordModal.errors.alreadyRecorded')
|
||||
: code === 'result_score_required' ||
|
||||
code === 'result_incomplete_breakdowns' ||
|
||||
code === 'result_breakdowns_required'
|
||||
? t('result.recordModal.scoresRequired', { count: unscored.length || 1 })
|
||||
: code === 'result_remark_required'
|
||||
? t('result.recordModal.reasonRequired')
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -191,6 +222,16 @@ export function RecordResultModal({
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
{alreadyMarked > 0 && (
|
||||
<Alert color="grape" icon={<IconInfoCircle size={15} />}>
|
||||
{t('result.recordModal.alreadyMarkedHint', { count: alreadyMarked })}
|
||||
</Alert>
|
||||
)}
|
||||
{sat.length > 0 && seafarerOptions.length === 0 && (
|
||||
<Alert color="teal" icon={<IconCheck size={15} />}>
|
||||
{t('result.recordModal.allMarked')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{selectedSeafarerId && questions.length > 0 && (
|
||||
<>
|
||||
@@ -226,18 +267,30 @@ export function RecordResultModal({
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{unscored.length > 0 && (
|
||||
<Text fz="xs" c="orange">
|
||||
{t('result.recordModal.scoresRequired', { count: unscored.length })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t('result.recordModal.remarkOptional')}
|
||||
placeholder={t('result.recordModal.remarkPlaceholder')}
|
||||
label={t('result.recordModal.reason')}
|
||||
placeholder={t('result.recordModal.reasonPlaceholder')}
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
|
||||
<Button onClick={handleSave} size="sm" loading={isSaving}>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
size="sm"
|
||||
loading={isSaving}
|
||||
disabled={unscored.length > 0 || !remark.trim()}
|
||||
>
|
||||
{t('result.saveResult')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import { IconDotsVertical, IconEye, IconLock, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -21,6 +21,10 @@ export function resultActionsColumn(
|
||||
label: t('result.columns.actions', 'Actions'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
// An engine-produced mark is immutable outside the appeal workflow, so
|
||||
// the moderation/return/delete entries are not offered for it — the API
|
||||
// refuses them anyway (result_locked_auto_graded).
|
||||
const locked = r.autoGraded && !r.appealUnlockId;
|
||||
return (
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
@@ -29,16 +33,21 @@ export function resultActionsColumn(
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => handlers.onViewDetail(r)}>
|
||||
{t('result.action.viewEdit')}
|
||||
<Menu.Item
|
||||
leftSection={locked ? <IconLock size={14} /> : <IconEye size={14} />}
|
||||
onClick={() => handlers.onViewDetail(r)}
|
||||
>
|
||||
{locked ? t('result.action.view') : t('result.action.viewEdit')}
|
||||
</Menu.Item>
|
||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||
<>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Item color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
||||
{t('result.review.moderate')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
{!locked && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Item color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
||||
{t('result.review.moderate')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Item color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
||||
{t('result.review.approve')}
|
||||
@@ -46,7 +55,7 @@ export function resultActionsColumn(
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
{!locked && (r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
<RequirePermission
|
||||
anyOf={[
|
||||
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
|
||||
@@ -66,20 +75,22 @@ export function resultActionsColumn(
|
||||
leftSection={<IconSend size={14} />}
|
||||
onClick={() => handlers.onPublish(r)}
|
||||
>
|
||||
{t('result.review.publish')}
|
||||
{t('result.review.publishOne')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{!locked && r.reviewStatus !== 'PUBLISHED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(r)}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(r)}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useCallback, type ElementType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
|
||||
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput, Alert} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
|
||||
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend, IconLock} from '@tabler/icons-react';
|
||||
import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useModerateResultMutation,
|
||||
useApproveResultMutation,
|
||||
useReturnResultMutation,
|
||||
usePublishResultMutation,
|
||||
usePublishExamResultsMutation,
|
||||
} from '../../api/result-api';
|
||||
import { useGetExamsQuery } from '../../../exam/api/exam-api';
|
||||
@@ -89,6 +90,7 @@ export function ResultPage() {
|
||||
const [approveResult, { isLoading: isApproving }] = useApproveResultMutation();
|
||||
const [returnResult, { isLoading: isReturning }] = useReturnResultMutation();
|
||||
const [publishResults, { isLoading: isPublishing }] = usePublishExamResultsMutation();
|
||||
const [publishOne, { isLoading: isPublishingOne }] = usePublishResultMutation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [examFilter, setExamFilter] = useState<string | null>(null);
|
||||
@@ -175,7 +177,9 @@ export function ResultPage() {
|
||||
notify.error(
|
||||
key === 'result_locked_after_approval'
|
||||
? t('result.review.lockedAfterApproval')
|
||||
: key,
|
||||
: key === 'result_locked_auto_graded'
|
||||
? t('result.review.autoGradedLocked')
|
||||
: key,
|
||||
);
|
||||
} finally {
|
||||
setDetailSaving(false);
|
||||
@@ -233,19 +237,33 @@ export function ResultPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */
|
||||
/**
|
||||
* Applicant-level publication: this row's candidate only. The exam and every
|
||||
* other candidate's mark are untouched — publishing A must never make B's
|
||||
* result visible or flip the session everyone shares.
|
||||
*/
|
||||
const handleConfirmPublish = async () => {
|
||||
if (!publishTarget) return;
|
||||
try {
|
||||
const outcome = await publishResults(publishTarget.examId).unwrap();
|
||||
notify.success(t('result.review.publishedCount', outcome));
|
||||
await publishOne(publishTarget.id).unwrap();
|
||||
notify.success(t('result.review.publishedOne'));
|
||||
closePublish();
|
||||
setPublishTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.review.error')));
|
||||
const key = extractErrorMessage(error, t('result.review.error'));
|
||||
notify.error(
|
||||
key === 'result_not_approved'
|
||||
? t('result.review.publishNeedsApproval')
|
||||
: key === 'result_already_published'
|
||||
? t('result.review.alreadyPublished')
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// The engine's mark is read-only here: only an upheld appeal reopens it.
|
||||
const detailLocked = Boolean(detailResult?.autoGraded && !detailResult?.appealUnlockId);
|
||||
|
||||
const handleDetailClose = () => {
|
||||
closeDetail();
|
||||
setDetailBreakdowns([]);
|
||||
@@ -413,6 +431,11 @@ export function ResultPage() {
|
||||
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${detailResult.reviewStatus}`)}
|
||||
</Badge>
|
||||
{detailResult.autoGraded && (
|
||||
<Badge variant="light" color="grape" leftSection={<IconLock size={10} />}>
|
||||
{t('result.review.autoGraded')}
|
||||
</Badge>
|
||||
)}
|
||||
{detailResult.preModerationScore != null && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('result.review.originalScore')}: {detailResult.preModerationScore}
|
||||
@@ -422,6 +445,12 @@ export function ResultPage() {
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{detailLocked && (
|
||||
<Alert color="grape" icon={<IconLock size={16} />}>
|
||||
{t('result.review.autoGradedLocked')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<BilingualInput
|
||||
label={t('result.detail.remark')}
|
||||
placeholder={{ en: 'Officer remark in English', am: 'የኃላፊ አስተያየት በአማርኛ' }}
|
||||
@@ -462,6 +491,8 @@ export function ResultPage() {
|
||||
type="number"
|
||||
style={{ width: 80 }}
|
||||
value={b.score}
|
||||
readOnly={detailLocked}
|
||||
disabled={detailLocked}
|
||||
onChange={(e) => {
|
||||
const updated = [...detailBreakdowns];
|
||||
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
|
||||
@@ -474,6 +505,8 @@ export function ResultPage() {
|
||||
size="xs"
|
||||
placeholder="Optional"
|
||||
value={b.remark ?? ''}
|
||||
readOnly={detailLocked}
|
||||
disabled={detailLocked}
|
||||
onChange={(e) => {
|
||||
const updated = [...detailBreakdowns];
|
||||
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
|
||||
@@ -501,6 +534,7 @@ export function ResultPage() {
|
||||
onClick={handleDetailSave}
|
||||
size="sm"
|
||||
loading={detailSaving}
|
||||
disabled={detailLocked}
|
||||
leftSection={<IconDeviceFloppy size={15} />}
|
||||
>
|
||||
{t('result.save')}
|
||||
@@ -568,16 +602,19 @@ export function ResultPage() {
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={publishOpened} onClose={closePublish} title={t('result.review.publish')} size="sm">
|
||||
<Modal opened={publishOpened} onClose={closePublish} title={t('result.review.publishOne')} size="sm">
|
||||
<Text mb="md">
|
||||
{t('result.review.publishConfirmText', {
|
||||
{t('result.review.publishOneConfirmText', {
|
||||
candidate: publishTarget?.seafarer
|
||||
? `${publishTarget.seafarer.firstName} ${publishTarget.seafarer.lastName}`
|
||||
: publishTarget?.seafarerId.slice(0, 8) ?? '',
|
||||
exam: publishTarget ? getExamTitle(publishTarget.examId) : '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closePublish} size="sm">{t('result.cancel')}</Button>
|
||||
<Button color="teal" loading={isPublishing} onClick={handleConfirmPublish} size="sm">
|
||||
{t('result.review.publish')}
|
||||
<Button color="teal" loading={isPublishingOne} onClick={handleConfirmPublish} size="sm">
|
||||
{t('result.review.publishOne')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -62,6 +62,13 @@ export interface Result {
|
||||
remark: { en: string; am: string } | null;
|
||||
status: ExamResultStatus;
|
||||
reviewStatus: ResultReviewStatus;
|
||||
/**
|
||||
* The exam engine produced this mark from the candidate's own answers and
|
||||
* the configured answer key. Locked against every ordinary edit — only an
|
||||
* upheld appeal reopens it — so the score inputs are read-only for it.
|
||||
*/
|
||||
autoGraded: boolean;
|
||||
appealUnlockId?: string | null;
|
||||
markedById: string | null;
|
||||
markedAt: string | null;
|
||||
preModerationScore: number | null;
|
||||
|
||||
@@ -225,6 +225,8 @@ export const am: Translations = {
|
||||
form: "ቅጽ",
|
||||
venue: "ቦታ",
|
||||
date: "ቀን",
|
||||
window: "የፈተና ሰዓት",
|
||||
allDay: "ቀኑን ሙሉ",
|
||||
administration: "አስተዳደር",
|
||||
evaluation: "ግምገማ",
|
||||
selection: "ምርጫ",
|
||||
@@ -250,6 +252,11 @@ export const am: Translations = {
|
||||
directionAm: "መመሪያ (አማርኛ)",
|
||||
directionAmPlaceholder: "መመሪያ በአማርኛ",
|
||||
examDate: "የፈተና ቀን",
|
||||
startTime: "የመጀመሪያ ሰዓት",
|
||||
endTime: "የመጨረሻ ሰዓት",
|
||||
windowHint:
|
||||
"አማራጭ። ተፈታኞች በፈተናው ቀን ከመጀመሪያ ሰዓት በፊት ወይም ከመጨረሻ ሰዓት በኋላ መጀመር አይችሉም (የአዲስ አበባ ሰዓት)። ቀኑን ሙሉ ክፍት ለማድረግ ባዶ ይተዉ።",
|
||||
windowInvalid: "የመጨረሻ ሰዓት ከመጀመሪያ ሰዓት በኋላ መሆን አለበት።",
|
||||
venue: "ቦታ",
|
||||
venuePlaceholder: "የፈተና ቦታ",
|
||||
timeAllowed: "የተፈቀደ ጊዜ",
|
||||
@@ -308,7 +315,6 @@ export const am: Translations = {
|
||||
COMPLETED: "ተጠናቋል",
|
||||
CANCELLED: "ተሰርዟል",
|
||||
POSTPONED: "ተላልፏል",
|
||||
PUBLISHED: "ታትሟል",
|
||||
},
|
||||
type: {
|
||||
WRITTEN: "ጽሑፍ",
|
||||
@@ -349,6 +355,14 @@ export const am: Translations = {
|
||||
regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።",
|
||||
regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።",
|
||||
regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።",
|
||||
result: "ውጤት",
|
||||
noResult: "አልተመዘነም",
|
||||
engineMarked: "በፈተና ሞተሩ ከመልስ ቁልፉ የተመዘነ — ተቆልፏል። ግምገማ፦ {{review}}።",
|
||||
examinerMarked: "በፈታኝ የተመዘነ። ግምገማ፦ {{review}}።",
|
||||
outcome: {
|
||||
PASSED: "አልፏል",
|
||||
FAILED: "አላለፈም",
|
||||
},
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: "አልተጠራም",
|
||||
@@ -398,6 +412,100 @@ export const am: Translations = {
|
||||
paperLocked: "ወረቀቱ ተቆልፏል",
|
||||
paperLockedHint:
|
||||
"ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።",
|
||||
errors: {
|
||||
scoringLocked:
|
||||
"ለዚህ ፈተና {{count}} ውጤት(ዎች) አስቀድመው ጸድቀዋል ወይም ወጥተዋል። የማለፊያ ነጥቡና የግምገማ ዘዴው ሊቀየሩ አይችሉም።",
|
||||
},
|
||||
questionErrors: {
|
||||
subjectMismatch: "አንድ ወይም ከዚያ በላይ ጥያቄዎች የዚህ ፈተና ትምህርት አይደሉም።",
|
||||
notFound: "አንድ ወይም ከዚያ በላይ ጥያቄዎች አልተገኙም።",
|
||||
},
|
||||
questionsMenu: {
|
||||
add: "ጥያቄ ጨምር",
|
||||
fromBank: "ከጥያቄ ባንክ ፍጠር",
|
||||
importExcel: "ከExcel አስገባ",
|
||||
fromScratch: "አዲስ ጥያቄ ጨምር",
|
||||
managePaper: "ሙሉ ወረቀቱን እንደገና መድብ",
|
||||
},
|
||||
bank: {
|
||||
title: "ከጥያቄ ባንክ ጨምር",
|
||||
hint: "ለዚህ ትምህርት የጸደቁና በወረቀቱ ላይ ያልተካተቱ ጥያቄዎች። የተመረጡት ከተመደቡት ጥያቄዎች በኋላ ይጨመራሉ።",
|
||||
search: "ጥያቄዎችን ፈልግ…",
|
||||
empty: "ለዚህ ትምህርት ሊጨመር የሚችል የጸደቀ ጥያቄ የለም።",
|
||||
add: "{{count}} ወደዚህ ፈተና ጨምር",
|
||||
added: "{{count}} ጥያቄ(ዎች) ወደ ወረቀቱ ተጨምረዋል",
|
||||
},
|
||||
newQuestion: {
|
||||
title: "ለዚህ ፈተና አዲስ ጥያቄ ጨምር",
|
||||
hint: "ጥያቄው በዚህ ፈተና ትምህርት ሥር ተፈጥሮ በአንድ እርምጃ በወረቀቱ ላይ ይቀመጣል። እንደ የጸደቀ ጥያቄ ወደ ባንኩ ይገባል።",
|
||||
titleEn: "ጥያቄ (እንግሊዝኛ)",
|
||||
titleAm: "ጥያቄ (አማርኛ)",
|
||||
form: "ዓይነት",
|
||||
points: "ነጥብ",
|
||||
options: "አማራጮች",
|
||||
optionEn: "አማራጭ {{number}} (እንግሊዝኛ)",
|
||||
optionAm: "አማራጭ {{number}} (አማርኛ)",
|
||||
correct: "ትክክል",
|
||||
addOption: "አማራጭ ጨምር",
|
||||
create: "ፍጠርና ወደ ፈተና ጨምር",
|
||||
created: "ጥያቄው ተፈጥሮ ወደ ወረቀቱ ተጨምሯል",
|
||||
fillRequired: "የጥያቄውን ጽሑፍ፣ ዓይነትና ከዜሮ በላይ ነጥብ ያስገቡ።",
|
||||
needTwo: "የምርጫ ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልጉታል።",
|
||||
needCorrect: "ቢያንስ አንድ አማራጭ ትክክል ብለው ይምረጡ።",
|
||||
textRequired: "እያንዳንዱ አማራጭ የእንግሊዝኛ ጽሑፍ ያስፈልገዋል።",
|
||||
},
|
||||
import: {
|
||||
title: "ጥያቄዎችን ከExcel አስገባ",
|
||||
hint: "በእያንዳንዱ ረድፍ አንድ ጥያቄ ያለውን ፋይል ይጫኑ፣ ያረጋግጡ፣ ቅድመ እይታውን ይመልከቱ፣ ከዚያ ያስገቡ። አንድ ረድፍ ችግር ካለው ምንም አይገባም።",
|
||||
template: "ቅጹን አውርድ",
|
||||
file: "የExcel ፋይል (.xlsx)",
|
||||
validate: "አረጋግጥ",
|
||||
import: "አስገባ",
|
||||
preview: "ቅድመ እይታ — {{count}} ጥያቄ(ዎች)",
|
||||
valid: "ለማስገባት ዝግጁ",
|
||||
errors: "{{count}} ችግር(ዎች) ተገኝተዋል",
|
||||
nothingImported: "ከላይ ያሉትን ረድፎች አስተካክለው እንደገና ያረጋግጡ። ምንም አልገባም።",
|
||||
imported: "{{count}} ጥያቄ(ዎች) ወደ ወረቀቱ ገብተዋል",
|
||||
row: "ረድፍ",
|
||||
column: "አምድ",
|
||||
problem: "ችግር",
|
||||
question: "ጥያቄ",
|
||||
type: "ዓይነት",
|
||||
points: "ነጥብ",
|
||||
options: "አማራጮች",
|
||||
errorKeys: {
|
||||
question_text_required: "የጥያቄው ጽሑፍ (እንግሊዝኛ) ያስፈልጋል።",
|
||||
invalid_question_type: "ዓይነት CHOICE ወይም ESSAY መሆን አለበት።",
|
||||
invalid_points: "ነጥብ ከዜሮ የሚበልጥ ቁጥር መሆን አለበት።",
|
||||
options_required: "የምርጫ ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልጉታል (option_a_en, option_b_en, …)።",
|
||||
correct_answer_required: "ትክክለኛውን አማራጭ ፊደል በ“correct” አምድ ውስጥ ያመልክቱ።",
|
||||
correct_answer_invalid: "የ“correct” አምድ ያልተሞላ አማራጭን ያመለክታል።",
|
||||
duplicate_in_file: "ከረድፍ {{detail}} ጋር አንድ ዓይነት ጥያቄ።",
|
||||
duplicate_in_bank: "ይህ ጥያቄ ለዚህ ትምህርት በባንኩ ውስጥ አስቀድሞ አለ።",
|
||||
missing_columns: "የሚያስፈልጉ አምድ(ዎች) ጠፍተዋል፦ {{detail}}።",
|
||||
too_many_rows: "በአንድ ጊዜ ቢበዛ {{detail}} ረድፎች ማስገባት ይቻላል።",
|
||||
no_questions_in_file: "ሉሁ የጥያቄ ረድፍ የለውም።",
|
||||
invalid_excel_file: "ፋይሉ ሊነበብ የሚችል .xlsx አይደለም።",
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
section: "የፈተና መጠበቂያ መለኪያዎች",
|
||||
hint: "ከምዝገባ፣ ከተገኝነትና ከፈተና መጀመሪያ ጊዜ መዝገቦች የተገኘ። ለትንተና ብቻ — ምዝገባን፣ ተገኝነትን፣ ውጤትን ወይም ሰርተፍኬትን አይቀይርም።",
|
||||
candidates: "{{count}} ተመዝግበዋል",
|
||||
attended: "{{count}} ተገኝተዋል",
|
||||
started: "{{count}} ጀምረዋል",
|
||||
registrationToScheduled: "ምዝገባ → የተያዘ መጀመሪያ",
|
||||
scheduledToAttendance: "የተያዘ መጀመሪያ → መግባት",
|
||||
attendanceToExamStart: "መግባት → የፈተና መጀመሪያ",
|
||||
scheduledToExamStart: "የተያዘ መጀመሪያ → የፈተና መጀመሪያ",
|
||||
average: "አማካይ",
|
||||
min: "ዝቅተኛ",
|
||||
max: "ከፍተኛ",
|
||||
count: "ተፈታኞች",
|
||||
minutes: "{{value}} ደቂቃ",
|
||||
hours: "{{value}} ሰዓት",
|
||||
days: "{{value}} ቀን",
|
||||
},
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -698,6 +806,19 @@ export const am: Translations = {
|
||||
remark: "ማስታወሻ",
|
||||
remarkOptional: "ማስታወሻ (አማራጭ)",
|
||||
remarkPlaceholder: "የኦፊሰር ማስታወሻ",
|
||||
reason: "ምክንያት / አስተያየት",
|
||||
reasonPlaceholder: "ተፈታኙ እነዚህ ነጥቦች የተሰጡበት ምክንያት — ግዴታ",
|
||||
reasonRequired: "በእጅ ለሚመዘገብ ውጤት ምክንያት ያስፈልጋል።",
|
||||
scorePlaceholder: "ነጥብ",
|
||||
scoresRequired: "{{count}} ጥያቄ(ዎች) እስካሁን ነጥብ የላቸውም። እያንዳንዱ ጥያቄ ነጥብ ያስፈልገዋል — ባዶ ሳጥን ዜሮ አይደለም።",
|
||||
alreadyMarkedHint:
|
||||
"በዚህ ፈተና {{count}} ተፈታኝ(ዎች) አስቀድመው ውጤት አላቸውና አልተዘረዘሩም — የፈተና ሞተሩ ወይም ፈታኝ ወረቀታቸውን መዝኗል። እነዚያን ለመመልከት የፈተና ውጤቶችን ይጠቀሙ።",
|
||||
allMarked: "በዚህ ፈተና የተፈተኑ ሁሉ አስቀድመው ውጤት አላቸው። በእጅ የሚመዘገብ ምንም የለም።",
|
||||
errors: {
|
||||
notRegistered: "ይህ ተፈታኝ ለዚህ ፈተና አልተመዘገበም።",
|
||||
notPresent: "የሚመዘን ወረቀት የለም — መዝገቡ {{ruling}} ይላል።",
|
||||
alreadyRecorded: "ለዚህ ተፈታኝ ውጤት አስቀድሞ ተመዝግቧል።",
|
||||
},
|
||||
totalScore: "ጠቅላላ ውጤት",
|
||||
passMark: "ማለፊያ ውጤት",
|
||||
status: "ሁኔታ",
|
||||
@@ -714,6 +835,7 @@ export const am: Translations = {
|
||||
},
|
||||
action: {
|
||||
viewEdit: "ተመልከት / አስተካክል",
|
||||
view: "ተመልከት",
|
||||
delete: "ሰርዝ",
|
||||
},
|
||||
search: {
|
||||
@@ -763,6 +885,15 @@ export const am: Translations = {
|
||||
publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።",
|
||||
publishConfirmText:
|
||||
"ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?",
|
||||
publishOne: "ይህን ውጤት አውጣ",
|
||||
publishOneConfirmText:
|
||||
"የ{{candidate}} የ{{exam}} ውጤት ይውጣ? ይህ ተፈታኝ ብቻ ይነገረዋል፣ የእሱ/የእሷ ማመልከቻ ብቻ ይቀጥላል — ፈተናውና ሌሎች ተፈታኞች አይነኩም።",
|
||||
publishedOne: "ውጤቱ ለተፈታኙ ወጥቷል",
|
||||
publishNeedsApproval: "ውጤት ከመውጣቱ በፊት መጽደቅ አለበት።",
|
||||
alreadyPublished: "ይህ ውጤት አስቀድሞ ወጥቷል።",
|
||||
autoGraded: "በራስ-ሰር የተገመገመ",
|
||||
autoGradedLocked:
|
||||
"ይህ ውጤት በፈተና ሞተሩ ከተፈታኙ መልሶችና ከመልስ ቁልፉ ተሰልቷል። ተቆልፏል፦ ነጥቦች ሊስተካከሉ፣ ሊመረመሩ ወይም ሊሰረዙ አይችሉም። የተቀበለ ይግባኝ ብቻ ለድጋሚ እርማት ይከፍተዋል።",
|
||||
lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።",
|
||||
originalScore: "የፈታኙ ጠቅላላ",
|
||||
derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",
|
||||
|
||||
@@ -224,6 +224,8 @@ export const en = {
|
||||
form: 'Form',
|
||||
venue: 'Venue',
|
||||
date: 'Date',
|
||||
window: 'Session time',
|
||||
allDay: 'All day',
|
||||
administration: 'Administration',
|
||||
evaluation: 'Evaluation',
|
||||
selection: 'Selection',
|
||||
@@ -249,6 +251,11 @@ export const en = {
|
||||
directionAm: 'Direction (Amharic)',
|
||||
directionAmPlaceholder: 'መመሪያ በአማርኛ',
|
||||
examDate: 'Exam Date',
|
||||
startTime: 'Start time',
|
||||
endTime: 'End time',
|
||||
windowHint:
|
||||
'Optional. Candidates cannot start before the start time, or after the end time, on the exam date (Addis Ababa time). Leave blank to open the whole day.',
|
||||
windowInvalid: 'The end time must be after the start time.',
|
||||
venue: 'Venue',
|
||||
venuePlaceholder: 'Exam venue',
|
||||
timeAllowed: 'Time Allowed',
|
||||
@@ -306,7 +313,6 @@ export const en = {
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
POSTPONED: 'Postponed',
|
||||
PUBLISHED: 'Published',
|
||||
},
|
||||
type: {
|
||||
WRITTEN: 'Written',
|
||||
@@ -347,6 +353,14 @@ export const en = {
|
||||
regraded: 'Result created from the graded attempt.',
|
||||
regradeNotEligible: 'Not auto-gradable: {{reason}}. Use Record Result instead.',
|
||||
regradeError: 'Could not regrade this attempt.',
|
||||
result: 'Result',
|
||||
noResult: 'Not marked',
|
||||
engineMarked: 'Marked by the exam engine from the answer key — locked. Review: {{review}}.',
|
||||
examinerMarked: 'Marked by an examiner. Review: {{review}}.',
|
||||
outcome: {
|
||||
PASSED: 'Passed',
|
||||
FAILED: 'Failed',
|
||||
},
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: 'Not called',
|
||||
@@ -397,6 +411,100 @@ export const en = {
|
||||
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.',
|
||||
errors: {
|
||||
scoringLocked:
|
||||
'{{count}} result(s) for this session are already approved or published. The pass mark and evaluation method cannot change under them.',
|
||||
},
|
||||
questionErrors: {
|
||||
subjectMismatch: 'One or more questions belong to a different subject than this exam.',
|
||||
notFound: 'One or more questions could not be found.',
|
||||
},
|
||||
questionsMenu: {
|
||||
add: 'Add question',
|
||||
fromBank: 'Create from question bank',
|
||||
importExcel: 'Import from Excel',
|
||||
fromScratch: 'Add new question',
|
||||
managePaper: 'Reassign whole paper',
|
||||
},
|
||||
bank: {
|
||||
title: 'Add from the question bank',
|
||||
hint: 'Approved questions for this subject that are not yet on the paper. Selected items are added after the questions already assigned.',
|
||||
search: 'Search questions…',
|
||||
empty: 'No approved questions for this subject are available to add.',
|
||||
add: 'Add {{count}} to this exam',
|
||||
added: '{{count}} question(s) added to the paper',
|
||||
},
|
||||
newQuestion: {
|
||||
title: 'Add a new question to this exam',
|
||||
hint: 'The question is created under this exam’s subject and placed on its paper in one step. It joins the bank as an approved item.',
|
||||
titleEn: 'Question (English)',
|
||||
titleAm: 'Question (Amharic)',
|
||||
form: 'Type',
|
||||
points: 'Points',
|
||||
options: 'Options',
|
||||
optionEn: 'Option {{number}} (English)',
|
||||
optionAm: 'Option {{number}} (Amharic)',
|
||||
correct: 'Correct',
|
||||
addOption: 'Add option',
|
||||
create: 'Create and add to exam',
|
||||
created: 'Question created and added to the paper',
|
||||
fillRequired: 'Enter the question text, type and a positive number of points.',
|
||||
needTwo: 'A choice question needs at least two options.',
|
||||
needCorrect: 'Mark at least one option as correct.',
|
||||
textRequired: 'Every option needs its English text.',
|
||||
},
|
||||
import: {
|
||||
title: 'Import questions from Excel',
|
||||
hint: 'Upload a workbook with one question per row, validate it, review the preview, then import. If any row has a problem, nothing is imported.',
|
||||
template: 'Download the template',
|
||||
file: 'Excel file (.xlsx)',
|
||||
validate: 'Validate',
|
||||
import: 'Import',
|
||||
preview: 'Preview — {{count}} question(s)',
|
||||
valid: 'Ready to import',
|
||||
errors: '{{count}} problem(s) found',
|
||||
nothingImported: 'Fix the rows above and validate again. Nothing has been imported.',
|
||||
imported: 'Imported {{count}} question(s) onto the paper',
|
||||
row: 'Row',
|
||||
column: 'Column',
|
||||
problem: 'Problem',
|
||||
question: 'Question',
|
||||
type: 'Type',
|
||||
points: 'Points',
|
||||
options: 'Options',
|
||||
errorKeys: {
|
||||
question_text_required: 'The question text (English) is required.',
|
||||
invalid_question_type: 'Type must be CHOICE or ESSAY.',
|
||||
invalid_points: 'Points must be a number greater than zero.',
|
||||
options_required: 'A CHOICE question needs at least two options (option_a_en, option_b_en, …).',
|
||||
correct_answer_required: 'Mark the correct option letter(s) in the “correct” column.',
|
||||
correct_answer_invalid: 'The “correct” column names an option that is not filled in.',
|
||||
duplicate_in_file: 'Same question as row {{detail}}.',
|
||||
duplicate_in_bank: 'This question already exists in the bank for this subject.',
|
||||
missing_columns: 'Required column(s) missing: {{detail}}.',
|
||||
too_many_rows: 'At most {{detail}} rows can be imported at once.',
|
||||
no_questions_in_file: 'The sheet has no question rows.',
|
||||
invalid_excel_file: 'The file is not a readable .xlsx workbook.',
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
section: 'Exam wait metrics',
|
||||
hint: 'Derived from the registration, attendance and exam-start timestamps already on record. Analytical only — nothing here changes a registration, attendance, result or certificate.',
|
||||
candidates: '{{count}} registered',
|
||||
attended: '{{count}} checked in',
|
||||
started: '{{count}} started',
|
||||
registrationToScheduled: 'Registration → scheduled start',
|
||||
scheduledToAttendance: 'Scheduled start → check-in',
|
||||
attendanceToExamStart: 'Check-in → exam start',
|
||||
scheduledToExamStart: 'Scheduled start → exam start',
|
||||
average: 'average',
|
||||
min: 'min',
|
||||
max: 'max',
|
||||
count: 'candidates',
|
||||
minutes: '{{value}} min',
|
||||
hours: '{{value}} h',
|
||||
days: '{{value}} d',
|
||||
},
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -699,6 +807,19 @@ export const en = {
|
||||
remark: 'Remark',
|
||||
remarkOptional: 'Remark (optional)',
|
||||
remarkPlaceholder: 'Officer remarks',
|
||||
reason: 'Reason / remarks',
|
||||
reasonPlaceholder: 'Why the candidate is awarded these marks — required',
|
||||
reasonRequired: 'A reason is required for a manually recorded result.',
|
||||
scorePlaceholder: 'Mark',
|
||||
scoresRequired: '{{count}} question(s) have no mark yet. Every question needs a mark — an empty box is not a zero.',
|
||||
alreadyMarkedHint:
|
||||
'{{count}} candidate(s) on this session already have a result and are not listed — the exam engine or an examiner has marked their paper. Use Exam Results to review those.',
|
||||
allMarked: 'Every candidate who sat this session already has a result. There is nothing left to record by hand.',
|
||||
errors: {
|
||||
notRegistered: 'This candidate is not registered for the session.',
|
||||
notPresent: 'No paper to mark — the register says {{ruling}}.',
|
||||
alreadyRecorded: 'A result has already been recorded for this candidate.',
|
||||
},
|
||||
totalScore: 'Total Score',
|
||||
passMark: 'Pass Mark',
|
||||
status: 'Status',
|
||||
@@ -715,6 +836,7 @@ export const en = {
|
||||
},
|
||||
action: {
|
||||
viewEdit: 'View / Edit',
|
||||
view: 'View',
|
||||
delete: 'Delete',
|
||||
},
|
||||
search: {
|
||||
@@ -765,6 +887,15 @@ export const en = {
|
||||
publishNeedsExam: 'Filter by an exam first to publish its results.',
|
||||
publishConfirmText:
|
||||
'This publishes every approved result for {{exam}} — not just this one — and notifies each candidate. Continue?',
|
||||
publishOne: 'Publish this result',
|
||||
publishOneConfirmText:
|
||||
'Publish the result of {{candidate}} for {{exam}}? Only this candidate is notified and only their application advances — the exam and every other candidate are unaffected.',
|
||||
publishedOne: 'Result published to the candidate',
|
||||
publishNeedsApproval: 'A result must be approved before it can be published.',
|
||||
alreadyPublished: 'This result has already been published.',
|
||||
autoGraded: 'Auto-graded',
|
||||
autoGradedLocked:
|
||||
'This mark was calculated by the exam engine from the candidate’s answers and the answer key. It is locked: scores cannot be edited, moderated or deleted. Only an upheld appeal reopens it for re-marking.',
|
||||
lockedAfterApproval:
|
||||
'This result is approved and can no longer be edited. Return it to the examiner first.',
|
||||
originalScore: 'Examiner total',
|
||||
|
||||
@@ -121,6 +121,7 @@ const EXAM_STAGE_LABELS: Record<string, string> = {
|
||||
EXAM_PAID: 'Exam Paid',
|
||||
REGISTERED: 'Exam Scheduled',
|
||||
ATTENDANCE_CONFIRMED: 'Exam Attendance Confirmed',
|
||||
NOT_SITTING: 'Not Sitting — See Exam Registration',
|
||||
SITTING: 'Exam In Progress',
|
||||
UNDER_EVALUATION: 'Exam Completed — Under Evaluation',
|
||||
PASSED: 'Passed — Certificate Fee Due',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Alert, Badge, Button, Card, Group, Stack, Text, Title } from '@mantine/
|
||||
import { IconAlertCircle, IconClock, IconInfoCircle, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
import type { EstimatedTime, RegistrationWithExam } from '../types/exam-attempt';
|
||||
import { examWindowState, shortTime, windowLabel } from '../../exams/exam-window';
|
||||
|
||||
function formatDuration(time: EstimatedTime | null | undefined): string {
|
||||
if (!time) return 'Not configured';
|
||||
@@ -27,7 +28,10 @@ export function ExamInstructions({
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const exam = registration.exam;
|
||||
const canStart = exam?.status === 'ACTIVE';
|
||||
// The portal's reading of the session window, so the page says why the
|
||||
// button is shut. The server decides on its own clock regardless.
|
||||
const window = exam ? examWindowState(exam) : 'OPEN';
|
||||
const canStart = exam?.status === 'ACTIVE' && window === 'OPEN';
|
||||
|
||||
return (
|
||||
<Stack maw={720} mx="auto" gap="md">
|
||||
@@ -42,7 +46,11 @@ export function ExamInstructions({
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Session date</Text>
|
||||
<Text fz="sm">{showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''}</Text>
|
||||
<Text fz="sm">
|
||||
{showDate(exam?.date)}
|
||||
{exam && windowLabel(exam) ? ` · ${windowLabel(exam)}` : ''}
|
||||
{exam?.venue ? ` · ${exam.venue}` : ''}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Duration</Text>
|
||||
@@ -72,7 +80,18 @@ export function ExamInstructions({
|
||||
the exam ends the moment the deadline passes, whether or not you have submitted.
|
||||
</Alert>
|
||||
|
||||
{!canStart && (
|
||||
{exam?.status === 'ACTIVE' && window === 'NOT_STARTED' && (
|
||||
<Alert color="gray" variant="light">
|
||||
This session opens on {showDate(exam.date)} at {shortTime(exam.startTime) ?? '00:00'}.
|
||||
The exam cannot be started before then.
|
||||
</Alert>
|
||||
)}
|
||||
{exam?.status === 'ACTIVE' && window === 'CLOSED' && (
|
||||
<Alert color="gray" variant="light">
|
||||
This session's start window has closed.
|
||||
</Alert>
|
||||
)}
|
||||
{exam?.status !== 'ACTIVE' && (
|
||||
<Alert color="gray" variant="light">
|
||||
This session is not currently open for candidates to begin.
|
||||
</Alert>
|
||||
|
||||
@@ -217,12 +217,21 @@ export function useExamAttempt(examId: string | undefined) {
|
||||
seedFrom(result);
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Could not start the exam.');
|
||||
const [code, detail = ''] = key.split(':');
|
||||
notify.error(
|
||||
key === 'attendance_not_confirmed'
|
||||
code === 'attendance_not_confirmed'
|
||||
? 'An invigilator must confirm you are present before this exam opens.'
|
||||
: key === 'candidate_not_present'
|
||||
: code === 'candidate_not_present'
|
||||
? 'Your attendance record does not permit sitting this examination.'
|
||||
: key,
|
||||
: code === 'exam_not_started'
|
||||
? 'This examination has not opened yet. It can only be started at its scheduled date and time.'
|
||||
: code === 'exam_window_closed'
|
||||
? "This session's start window has closed."
|
||||
: code === 'exam_prerequisite_not_met'
|
||||
? `You are not eligible to sit this ${detail || ''} examination — no application of yours is awaiting it.`
|
||||
: code === 'exam_prerequisite_missing'
|
||||
? `A prerequisite certificate is not held: ${detail.split(',').join(', ')}.`
|
||||
: key,
|
||||
);
|
||||
}
|
||||
}, [examId, startTrigger, seedFrom]);
|
||||
|
||||
@@ -70,6 +70,9 @@ export interface RegistrationWithExam {
|
||||
title: Bilingual;
|
||||
direction?: Bilingual;
|
||||
date: string;
|
||||
/** `HH:MM[:SS]` the session opens on `date`; null means the start of the day. */
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
venue: string | null;
|
||||
status: string;
|
||||
givenTime: EstimatedTime | null;
|
||||
|
||||
39
apps/portal/src/app/features/exams/exam-window.spec.ts
Normal file
39
apps/portal/src/app/features/exams/exam-window.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { examWindowState, scheduledStartOf, windowLabel, zonedInstant } from './exam-window';
|
||||
|
||||
/**
|
||||
* The portal's reading of the session window must agree with the server's
|
||||
* (emaapi exam-window.ts): Addis Ababa is UTC+3, so a 10:00 sitting on
|
||||
* 10 September opens at 07:00Z whatever the device's zone.
|
||||
*/
|
||||
describe('exam-window (portal)', () => {
|
||||
const exam = { date: '2026-09-10', startTime: '10:00:00', endTime: '12:00:00' };
|
||||
|
||||
it('converts the configured wall-clock time through the authority timezone', () => {
|
||||
expect(scheduledStartOf(exam).toISOString()).toBe('2026-09-10T07:00:00.000Z');
|
||||
});
|
||||
|
||||
it('is NOT_STARTED the evening before, even though the date is tomorrow', () => {
|
||||
expect(examWindowState(exam, zonedInstant('2026-09-09', '17:00'))).toBe('NOT_STARTED');
|
||||
});
|
||||
|
||||
it('opens at the configured minute and closes after the end', () => {
|
||||
expect(examWindowState(exam, zonedInstant('2026-09-10', '09:59'))).toBe('NOT_STARTED');
|
||||
expect(examWindowState(exam, zonedInstant('2026-09-10', '10:00'))).toBe('OPEN');
|
||||
expect(examWindowState(exam, zonedInstant('2026-09-10', '12:00'))).toBe('OPEN');
|
||||
expect(examWindowState(exam, zonedInstant('2026-09-10', '12:01'))).toBe('CLOSED');
|
||||
});
|
||||
|
||||
it('treats a session with no times as open all day', () => {
|
||||
const allDay = { date: '2026-09-10', startTime: null, endTime: null };
|
||||
expect(examWindowState(allDay, zonedInstant('2026-09-09', '23:59'))).toBe('NOT_STARTED');
|
||||
expect(examWindowState(allDay, zonedInstant('2026-09-10', '00:00'))).toBe('OPEN');
|
||||
expect(examWindowState(allDay, zonedInstant('2026-09-10', '23:59'))).toBe('OPEN');
|
||||
});
|
||||
|
||||
it('labels the window for display', () => {
|
||||
expect(windowLabel(exam)).toBe('10:00 – 12:00');
|
||||
expect(windowLabel({ date: '2026-09-10', startTime: '10:00' })).toBe('from 10:00');
|
||||
expect(windowLabel({ date: '2026-09-10' })).toBeNull();
|
||||
});
|
||||
});
|
||||
84
apps/portal/src/app/features/exams/exam-window.ts
Normal file
84
apps/portal/src/app/features/exams/exam-window.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Where "now" sits relative to a session's configured window — the portal's
|
||||
* own reading, for disabling "Take exam" and explaining why, before the
|
||||
* candidate hits the server's `exam_not_started` refusal.
|
||||
*
|
||||
* Display convenience only: the backend decides on its own clock
|
||||
* (ExamAttemptService, exam-window.ts) and this mirrors its arithmetic. A
|
||||
* device with a wrong clock gets a wrong button state, never a wrong exam.
|
||||
*/
|
||||
export const EXAM_TIMEZONE = 'Africa/Addis_Ababa';
|
||||
|
||||
export interface ExamScheduleLike {
|
||||
date: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
}
|
||||
|
||||
export type ExamWindowState = 'NOT_STARTED' | 'OPEN' | 'CLOSED';
|
||||
|
||||
function offsetMinutesAt(instant: Date, timeZone: string): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).formatToParts(instant);
|
||||
const read = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0);
|
||||
const asUtc = Date.UTC(
|
||||
read('year'),
|
||||
read('month') - 1,
|
||||
read('day'),
|
||||
read('hour'),
|
||||
read('minute'),
|
||||
read('second'),
|
||||
);
|
||||
return Math.round((asUtc - instant.getTime()) / 60_000);
|
||||
}
|
||||
|
||||
/** The instant that `YYYY-MM-DD` + `HH:MM[:SS]` denotes in `timeZone`. */
|
||||
export function zonedInstant(
|
||||
dateString: string,
|
||||
time: string,
|
||||
timeZone: string = EXAM_TIMEZONE,
|
||||
): Date {
|
||||
const [year, month, day] = dateString.slice(0, 10).split('-').map(Number);
|
||||
const [hour, minute, second = 0] = time.split(':').map(Number);
|
||||
const naive = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
const first = naive - offsetMinutesAt(new Date(naive), timeZone) * 60_000;
|
||||
return new Date(naive - offsetMinutesAt(new Date(first), timeZone) * 60_000);
|
||||
}
|
||||
|
||||
export function scheduledStartOf(exam: ExamScheduleLike): Date {
|
||||
return zonedInstant(exam.date, exam.startTime?.trim() || '00:00:00');
|
||||
}
|
||||
|
||||
export function scheduledEndOf(exam: ExamScheduleLike): Date {
|
||||
return zonedInstant(exam.date, exam.endTime?.trim() || '23:59:59');
|
||||
}
|
||||
|
||||
export function examWindowState(exam: ExamScheduleLike, now: Date = new Date()): ExamWindowState {
|
||||
if (now.getTime() < scheduledStartOf(exam).getTime()) return 'NOT_STARTED';
|
||||
if (now.getTime() > scheduledEndOf(exam).getTime()) return 'CLOSED';
|
||||
return 'OPEN';
|
||||
}
|
||||
|
||||
/** "10:00" from a stored "10:00:00", or null when the session has no time. */
|
||||
export function shortTime(time: string | null | undefined): string | null {
|
||||
if (!time) return null;
|
||||
return time.slice(0, 5);
|
||||
}
|
||||
|
||||
/** "10:00 – 12:00", "from 10:00", "until 12:00", or null when nothing is configured. */
|
||||
export function windowLabel(exam: ExamScheduleLike): string | null {
|
||||
const start = shortTime(exam.startTime);
|
||||
const end = shortTime(exam.endTime);
|
||||
if (start && end) return `${start} – ${end}`;
|
||||
if (start) return `from ${start}`;
|
||||
if (end) return `until ${end}`;
|
||||
return null;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
MyRegistration,
|
||||
MyResult,
|
||||
} from './index';
|
||||
import { examWindowState, shortTime, windowLabel } from '../../exam-window';
|
||||
|
||||
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
REGISTERED: 'gray',
|
||||
@@ -55,7 +56,18 @@ export function registrationColumns(
|
||||
},
|
||||
{
|
||||
header: t('exams.columns.date'),
|
||||
cell: ({ row }) => deps.showDate(row.original.exam?.date),
|
||||
cell: ({ row }) => {
|
||||
const exam = row.original.exam;
|
||||
const label = exam ? windowLabel(exam) : null;
|
||||
return (
|
||||
<Text size="sm">
|
||||
{deps.showDate(exam?.date)}
|
||||
{label ? (
|
||||
<Text span size="xs" c="dimmed">{` · ${label}`}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('exams.columns.venue'),
|
||||
@@ -142,6 +154,29 @@ export function registrationColumns(
|
||||
);
|
||||
}
|
||||
if (exam?.status !== 'ACTIVE') return null;
|
||||
// The session window, on the portal's own clock — a courtesy so the
|
||||
// button says why it is shut. The server refuses on its clock
|
||||
// regardless (exam_not_started), whatever this device believes.
|
||||
const window = attemptStatus === 'IN_PROGRESS' ? 'OPEN' : examWindowState(exam);
|
||||
if (window === 'NOT_STARTED') {
|
||||
return (
|
||||
<Tooltip label={t('exams.columns.notYetOpenHint')} multiline w={240}>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t('exams.columns.notYetOpen', {
|
||||
date: deps.showDate(exam.date),
|
||||
time: shortTime(exam.startTime) ?? '00:00',
|
||||
})}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (window === 'CLOSED') {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t('exams.columns.windowClosed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -28,11 +28,15 @@ import {
|
||||
usePermissions,
|
||||
} from '@ema-platform/auth';
|
||||
import { registrationColumns, resultColumns } from './columns';
|
||||
import { windowLabel } from '../../exam-window';
|
||||
|
||||
export interface OpenExam {
|
||||
id: string;
|
||||
title: { en?: string; am?: string };
|
||||
date: string;
|
||||
/** `HH:MM[:SS]` the session opens on `date` (authority timezone); null means the start of the day. */
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
venue: string | null;
|
||||
status: string;
|
||||
certification?: { name?: { en?: string } };
|
||||
@@ -58,8 +62,33 @@ export interface MyRegistration {
|
||||
exam?: OpenExam;
|
||||
/** The candidate's online sitting, when one has been started. */
|
||||
attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
||||
/**
|
||||
* Where this sitting stands, derived server-side from the registration, the
|
||||
* attempt and the published mark — the same reading the back office shows
|
||||
* on the COC queue and detail, so the two screens cannot disagree.
|
||||
*/
|
||||
examState?: MyExamState;
|
||||
/** The published mark for this sitting; null until it is published. */
|
||||
result?: {
|
||||
id: string;
|
||||
status: 'PASSED' | 'FAILED';
|
||||
totalScore: number;
|
||||
autoGraded: boolean;
|
||||
publishedAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Mirrors the server's ExamState (ExamStateService / resolveExamState). */
|
||||
export type MyExamState =
|
||||
| 'NOT_REGISTERED'
|
||||
| 'REGISTERED'
|
||||
| 'ATTENDANCE_CONFIRMED'
|
||||
| 'NOT_SITTING'
|
||||
| 'IN_PROGRESS'
|
||||
| 'UNDER_EVALUATION'
|
||||
| 'PASSED'
|
||||
| 'FAILED';
|
||||
|
||||
export interface MyResult {
|
||||
id: string;
|
||||
totalScore: number;
|
||||
@@ -123,6 +152,32 @@ export function ExamsPage() {
|
||||
|
||||
const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id));
|
||||
|
||||
/**
|
||||
* The API's refusals, made readable. Eligibility keys carry detail after a
|
||||
* colon (`exam_prerequisite_missing:KEY1,KEY2`), which the message shows.
|
||||
*/
|
||||
const describeExamError = (key: string): string => {
|
||||
const [code, detail = ''] = key.split(':');
|
||||
switch (code) {
|
||||
case 'seafarer_registration_required':
|
||||
return t('exams.notify.seafarerRequired');
|
||||
case 'already_registered_for_exam':
|
||||
return t('exams.notify.alreadyRegistered');
|
||||
case 'subject_already_passed':
|
||||
return t('exams.notify.alreadyPassed');
|
||||
case 'subject_registration_pending':
|
||||
return t('exams.notify.registrationPending', { admission: detail });
|
||||
case 'exam_prerequisite_not_met':
|
||||
return t('exams.notify.notEligible', { category: detail });
|
||||
case 'exam_prerequisite_missing':
|
||||
return t('exams.notify.prerequisiteMissing', { keys: detail.split(',').join(', ') });
|
||||
case 'exam_date_has_passed':
|
||||
return t('exams.notify.datePassed');
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (exam: OpenExam) => {
|
||||
try {
|
||||
const result = (await registerTrigger({
|
||||
@@ -138,15 +193,7 @@ export function ExamsPage() {
|
||||
refetch();
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('exams.notify.registerFailed'));
|
||||
notify.error(
|
||||
key === 'seafarer_registration_required'
|
||||
? t('exams.notify.seafarerRequired')
|
||||
: key === 'already_registered_for_exam'
|
||||
? t('exams.notify.alreadyRegistered')
|
||||
: key === 'subject_already_passed'
|
||||
? t('exams.notify.alreadyPassed')
|
||||
: key,
|
||||
);
|
||||
notify.error(describeExamError(key));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,6 +264,7 @@ export function ExamsPage() {
|
||||
<Text size="xs" c="dimmed">
|
||||
{localized(exam.certification?.name)} ·{' '}
|
||||
{showDate(exam.date)}
|
||||
{windowLabel(exam) ? ` · ${windowLabel(exam)}` : ''}
|
||||
{exam.venue ? ` · ${exam.venue}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,70 @@ describe('examStageFor', () => {
|
||||
it('falls back to registered when the registration has not loaded yet', () => {
|
||||
expect(examStageFor({ status: 'EXAM_SCHEDULED' })).toBe('REGISTERED');
|
||||
});
|
||||
|
||||
/**
|
||||
* The server now says where the sitting stands (`examState`), derived from
|
||||
* the same rows the back office reads — including the published mark, which
|
||||
* the application status can lag behind. The portal must show the same
|
||||
* answer as the officer's screen.
|
||||
*/
|
||||
describe('with the server-derived state', () => {
|
||||
it('shows a published PASSED as passed even while the application still reads scheduled', () => {
|
||||
expect(
|
||||
examStageFor(
|
||||
{ status: 'EXAM_SCHEDULED' },
|
||||
registration({
|
||||
attendanceStatus: 'PRESENT',
|
||||
attempt: { status: 'SUBMITTED' },
|
||||
examState: 'PASSED',
|
||||
result: { id: 'r-1', status: 'PASSED', totalScore: 82, autoGraded: true, publishedAt: '2026-09-01' },
|
||||
}),
|
||||
),
|
||||
).toBe('PASSED');
|
||||
});
|
||||
|
||||
it('shows a published FAILED as failed the same way', () => {
|
||||
expect(
|
||||
examStageFor({ status: 'EXAM_SCHEDULED' }, registration({ examState: 'FAILED' })),
|
||||
).toBe('FAILED');
|
||||
});
|
||||
|
||||
it('prefers the server state over the portal reading of attendance and attempt', () => {
|
||||
expect(
|
||||
examStageFor(
|
||||
{ status: 'EXAM_SCHEDULED' },
|
||||
registration({ attendanceStatus: 'REGISTERED', examState: 'ATTENDANCE_CONFIRMED' }),
|
||||
),
|
||||
).toBe('ATTENDANCE_CONFIRMED');
|
||||
expect(
|
||||
examStageFor({ status: 'EXAM_SCHEDULED' }, registration({ examState: 'IN_PROGRESS' })),
|
||||
).toBe('SITTING');
|
||||
expect(
|
||||
examStageFor({ status: 'EXAM_SCHEDULED' }, registration({ examState: 'UNDER_EVALUATION' })),
|
||||
).toBe('UNDER_EVALUATION');
|
||||
});
|
||||
|
||||
it('names a candidate ruled absent or withdrawn as not sitting', () => {
|
||||
expect(
|
||||
examStageFor(
|
||||
{ status: 'EXAM_SCHEDULED' },
|
||||
registration({ attendanceStatus: 'ABSENT', examState: 'NOT_SITTING' }),
|
||||
),
|
||||
).toBe('NOT_SITTING');
|
||||
});
|
||||
|
||||
it('covers a registration whose application row has not caught up from EXAM_PAID', () => {
|
||||
expect(
|
||||
examStageFor({ status: 'EXAM_PAID' }, registration({ examState: 'REGISTERED' })),
|
||||
).toBe('REGISTERED');
|
||||
});
|
||||
|
||||
it('does not let the exam state override an application already past the exam leg', () => {
|
||||
expect(
|
||||
examStageFor({ status: 'PAYMENT_CONFIRMED' }, registration({ examState: 'PASSED' })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('registrationForApplication', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ export type ExamStage =
|
||||
| 'EXAM_PAID'
|
||||
| 'REGISTERED'
|
||||
| 'ATTENDANCE_CONFIRMED'
|
||||
| 'NOT_SITTING'
|
||||
| 'SITTING'
|
||||
| 'UNDER_EVALUATION'
|
||||
| 'PASSED'
|
||||
@@ -27,6 +28,23 @@ export type ExamStage =
|
||||
/** Attendance rulings that mean an invigilator confirmed the candidate. */
|
||||
const CONFIRMED = ['PRESENT', 'LATE'];
|
||||
|
||||
/**
|
||||
* The server's own reading of the sitting, mapped onto the portal's stages.
|
||||
* `GET /exams/registrations/mine` carries `examState` from the same rule the
|
||||
* back office applies (resolveExamState), and it already accounts for the
|
||||
* published mark — which the application status can lag behind.
|
||||
*/
|
||||
const FROM_SERVER_STATE: Record<NonNullable<MyRegistration['examState']>, ExamStage> = {
|
||||
NOT_REGISTERED: 'REGISTERED',
|
||||
REGISTERED: 'REGISTERED',
|
||||
ATTENDANCE_CONFIRMED: 'ATTENDANCE_CONFIRMED',
|
||||
NOT_SITTING: 'NOT_SITTING',
|
||||
IN_PROGRESS: 'SITTING',
|
||||
UNDER_EVALUATION: 'UNDER_EVALUATION',
|
||||
PASSED: 'PASSED',
|
||||
FAILED: 'FAILED',
|
||||
};
|
||||
|
||||
/**
|
||||
* Null for anything outside the examination leg, so callers can fall back to
|
||||
* the ordinary status label without a second condition.
|
||||
@@ -41,6 +59,18 @@ export function examStageFor(
|
||||
if (app.status === 'EXAM_PASSED') return 'PASSED';
|
||||
if (app.status === 'EXAM_FAILED') return 'FAILED';
|
||||
|
||||
// A published mark is the last word, whatever the application still says:
|
||||
// the back office derives its state from the same rows and shows the same
|
||||
// answer, so a portal reading "under evaluation" over a published PASSED
|
||||
// would put the two screens at odds. Only the exam leg is read this way —
|
||||
// an application already past it (paying, issued) keeps its own label.
|
||||
if (
|
||||
registration?.examState &&
|
||||
(app.status === 'EXAM_SCHEDULED' || app.status === 'EXAM_PAID')
|
||||
) {
|
||||
return FROM_SERVER_STATE[registration.examState];
|
||||
}
|
||||
|
||||
// The fee has cleared and nothing is left but for the candidate to pick a
|
||||
// session. Named after the status rather than after what the candidate
|
||||
// should do next: "Eligible to Register" read as a status of its own and
|
||||
|
||||
@@ -247,9 +247,10 @@ export const am: Translations = {
|
||||
EXAM_PAID: "የፈተና ክፍያ ተከፍሏል",
|
||||
REGISTERED: "የፈተና ቀን፦ {{date}}",
|
||||
ATTENDANCE_CONFIRMED: "መገኘት ተረጋግጧል",
|
||||
NOT_SITTING: "አይፈተኑም — የፈተና ምዝገባዎን ይመልከቱ",
|
||||
SITTING: "ፈተና በመካሄድ ላይ",
|
||||
UNDER_EVALUATION: "ፈተና ተጠናቋል — በግምገማ ላይ",
|
||||
PASSED: "አልፈዋል",
|
||||
PASSED: "አልፈዋል — የሰርተፍኬት ክፍያ ይጠበቃል",
|
||||
FAILED: "አላለፉም",
|
||||
},
|
||||
status: {
|
||||
@@ -1040,6 +1041,12 @@ export const am: Translations = {
|
||||
appealWindowClosed:
|
||||
'የይግባኝ ማቅረቢያ ጊዜው (ከታተመበት ቀን ጀምሮ {{days}} ቀናት) አልፏል።',
|
||||
appealAlreadyOpen: 'በዚህ ውጤት ላይ ይግባኝ አስቀድሞ በመታየት ላይ ነው።',
|
||||
registrationPending:
|
||||
'ለዚህ ትምህርት በመጠባበቅ ላይ ያለ ፈተና አለዎት ({{admission}})። እንደገና ከመመዝገብዎ በፊት ውጤቱ መውጣት አለበት።',
|
||||
notEligible:
|
||||
'ለዚህ የ{{category}} ፈተና ብቁ አይደሉም፦ በዚህ ደረጃ ፈተናውን የሚጠብቅ ማመልከቻ የለዎትም።',
|
||||
prerequisiteMissing: 'ለዚህ ፈተና ቅድመ-ሁኔታ የሆነ ሰርተፍኬት የለዎትም፦ {{keys}}።',
|
||||
datePassed: 'ይህ ፈተና አስቀድሞ ተካሂዷል።',
|
||||
},
|
||||
appealModal: {
|
||||
title: 'የዚህን ውጤት ግምገማ ይጠይቁ',
|
||||
@@ -1071,6 +1078,9 @@ export const am: Translations = {
|
||||
awaitingAttendanceHint:
|
||||
'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
|
||||
notSitting: 'አይፈተኑም',
|
||||
notYetOpen: '{{date}} {{time}} ይከፈታል',
|
||||
notYetOpenHint: 'ፈተናው በተያዘለት ቀንና ሰዓት ብቻ ሊጀመር ይችላል። አገልጋዩ በራሱ ሰዓት ይወስናል።',
|
||||
windowClosed: 'የመጀመሪያ ጊዜው ተዘግቷል',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'አልተጠራም',
|
||||
PRESENT: 'ተገኝቷል',
|
||||
|
||||
@@ -252,9 +252,10 @@ export const en = {
|
||||
EXAM_PAID: 'Exam Paid',
|
||||
REGISTERED: 'Exam scheduled: {{date}}',
|
||||
ATTENDANCE_CONFIRMED: 'Attendance confirmed',
|
||||
NOT_SITTING: 'Not sitting — see your exam registration',
|
||||
SITTING: 'Exam in progress',
|
||||
UNDER_EVALUATION: 'Exam completed — under evaluation',
|
||||
PASSED: 'Passed',
|
||||
PASSED: 'Passed — certificate fee due',
|
||||
FAILED: 'Not passed',
|
||||
},
|
||||
status: {
|
||||
@@ -1048,6 +1049,13 @@ export const en = {
|
||||
appealWindowClosed:
|
||||
'The appeal window ({{days}} days from publication) has closed.',
|
||||
appealAlreadyOpen: 'An appeal on this result is already being considered.',
|
||||
registrationPending:
|
||||
'You already have a sitting pending for this subject ({{admission}}). Its result must be published before you can register again.',
|
||||
notEligible:
|
||||
'You are not eligible for this {{category}} examination: none of your applications is awaiting it at this rank.',
|
||||
prerequisiteMissing:
|
||||
'A prerequisite certificate for this examination is not held: {{keys}}.',
|
||||
datePassed: 'This session has already taken place.',
|
||||
},
|
||||
appealModal: {
|
||||
title: 'Request a review of this result',
|
||||
@@ -1079,6 +1087,10 @@ export const en = {
|
||||
awaitingAttendanceHint:
|
||||
'An invigilator must confirm you are present before the exam opens.',
|
||||
notSitting: 'Not sitting',
|
||||
notYetOpen: 'Opens {{date}} {{time}}',
|
||||
notYetOpenHint:
|
||||
'The exam can only be started at its scheduled date and time. The server decides on its own clock.',
|
||||
windowClosed: 'Start window closed',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'Not called',
|
||||
PRESENT: 'Present',
|
||||
|
||||
@@ -392,6 +392,13 @@ export interface LicenseApplication {
|
||||
licenseType?: LicenseType;
|
||||
/** Denormalized from `licenseType.familyKind` at submission time. */
|
||||
familyKind: FamilyKind;
|
||||
/**
|
||||
* The examination leg as the server reads it off the sitting (registration,
|
||||
* attempt, published mark) — present on queue rows, null where the
|
||||
* application has no sitting. Outranks `status` for what the exam leg says:
|
||||
* a published PASSED shows as passed even while `status` still lags.
|
||||
*/
|
||||
examState?: ExamState | null;
|
||||
applicantUserId: string;
|
||||
parentApplicationId?: string | null;
|
||||
kind: ApplicationKind;
|
||||
|
||||
Reference in New Issue
Block a user