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