mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +00:00
fix(exam): reflect authoritative exam state and lock engine-graded results
- COC queue status cell shows the server-derived exam state (registered, present, sat, passed, failed) instead of the lagging application status. - Portal examStageFor prefers the server's examState so portal and back office never disagree; NOT_SITTING stage added. - Exam roster shows each candidate's result and lock; Regrade hidden once a result exists. - Record Result modal: only unmarked candidates, empty score boxes (no silent zeros), reason required, backend refusals translated. - Result page: auto-graded marks read-only, per-applicant publish. - Exam page: session window fields, Add Question menu (bank / Excel / scratch), wait metrics panel; PUBLISHED exam status removed. - Portal: exam window shown, early launch and eligibility refusals explained. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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;
|
||||
|
||||
Reference in New Issue
Block a user