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:
mihretu
2026-09-07 08:12:12 +00:00
parent 29990d3142
commit 24fb51de23
36 changed files with 1888 additions and 99 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export { QuestionBankPickerModal } from './QuestionBankPickerModal';
export { ExamQuestionCreateModal } from './ExamQuestionCreateModal';
export { ExamQuestionImportModal } from './ExamQuestionImportModal';
export { describeExamQuestionError } from './errors';

View File

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

View File

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

View File

@@ -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"),

View File

@@ -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"])}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",

View File

@@ -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 exams 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 candidates 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',

View File

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

View File

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

View File

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

View File

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

View 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();
});
});

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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: 'ተገኝቷል',

View File

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

View File

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