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

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