mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +00:00
- 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>
198 lines
6.7 KiB
TypeScript
198 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|