Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
Nati
2026-08-21 18:20:02 +00:00
33 changed files with 2684 additions and 309 deletions

View File

@@ -11,6 +11,7 @@ import type {
ExamIncident,
CreateIncidentPayload,
ResolveIncidentPayload,
RegradeOutcome,
} from '../types/exam';
const examApi = baseApi.injectEndpoints({
@@ -20,7 +21,9 @@ const examApi = baseApi.injectEndpoints({
providesTags: ['Api'],
}),
getExam: builder.query<Exam, string>({
query: (id) => `/exams/${id}?i=questions`,
// Nested relation so CHOICE questions carry their options here too —
// needed to print real answer choices instead of blank A/B/C/D lines.
query: (id) => `/exams/${id}?i=questions,questions.options`,
providesTags: ['Api'],
}),
createExam: builder.mutation<Exam, CreateExamPayload>({
@@ -90,6 +93,14 @@ const examApi = baseApi.injectEndpoints({
}),
invalidatesTags: ['Api'],
}),
/** Staff-triggered re-run of auto-grading for one finalized attempt. */
regradeAttempt: builder.mutation<RegradeOutcome, string>({
query: (attemptId) => ({
url: `/exam-attempts/${attemptId}/regrade`,
method: 'POST',
}),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
@@ -107,4 +118,5 @@ export const {
useGetExamIncidentsQuery,
useRecordIncidentMutation,
useResolveIncidentMutation,
useRegradeAttemptMutation,
} = examApi;

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconUserCheck } from '@tabler/icons-react';
import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
import { IconDotsVertical, 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';
@@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) =>
export function examCandidateColumns(
t: TFunction,
handlers: { onRecord: (registration: ExamRegistration) => void },
handlers: {
onRecord: (registration: ExamRegistration) => void;
onRegrade: (registration: ExamRegistration) => void;
regrading?: string | null;
},
): AdvancedColumn<ExamRegistration>[] {
return [
{
@@ -78,21 +82,51 @@ export function examCandidateColumns(
header: '',
label: t('exam.candidates.record'),
align: 'right',
cell: ({ row }) => (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
hideOnly
>
<Button
size="compact-xs"
variant="light"
leftSection={<IconUserCheck size={12} />}
onClick={() => handlers.onRecord(row.original)}
>
{t('exam.candidates.record')}
</Button>
</RequirePermission>
),
cell: ({ row }) => {
const attemptStatus = row.original.attempt?.status;
const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED';
return (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
loading={handlers.regrading === row.original.attempt?.id}
>
<IconDotsVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
hideOnly
>
<Menu.Item
leftSection={<IconUserCheck size={14} />}
onClick={() => handlers.onRecord(row.original)}
>
{t('exam.candidates.record')}
</Menu.Item>
</RequirePermission>
{canRegrade && (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]}
hideOnly
>
<Menu.Item
color="grape"
leftSection={<IconRefresh size={14} />}
onClick={() => handlers.onRegrade(row.original)}
>
{t('exam.candidates.regrade')}
</Menu.Item>
</RequirePermission>
)}
</Menu.Dropdown>
</Menu>
);
},
},
];
}

View File

@@ -18,6 +18,7 @@ import { extractErrorMessage } from '@ema-platform/api';
import {
useGetExamRegistrationsQuery,
useRecordAttendanceMutation,
useRegradeAttemptMutation,
} from '../../api/exam-api';
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
import { candidateName, examCandidateColumns } from './columns';
@@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
const { t } = useTranslation();
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
const [regradeAttempt] = useRegradeAttemptMutation();
const [regrading, setRegrading] = useState<string | null>(null);
const [target, setTarget] = useState<ExamRegistration | null>(null);
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
const [remark, setRemark] = useState('');
const table = useServerTable();
const regrade = async (registration: ExamRegistration) => {
const attemptId = registration.attempt?.id;
if (!attemptId) return;
setRegrading(attemptId);
try {
const outcome = await regradeAttempt(attemptId).unwrap();
if (outcome.graded) {
notify.success(t('exam.candidates.regraded'));
} else {
notify.error(t('exam.candidates.regradeNotEligible', { reason: outcome.reason }));
}
refetch();
} catch (error) {
notify.error(extractErrorMessage(error, t('exam.candidates.regradeError')));
} finally {
setRegrading(null);
}
};
const startRecording = (registration: ExamRegistration) => {
setTarget(registration);
setStatus(
@@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
) : (
<AdvancedTable
tableName={t('exam.candidates.section')}
columns={examCandidateColumns(t, { onRecord: startRecording })}
columns={examCandidateColumns(t, {
onRecord: startRecording,
onRegrade: regrade,
regrading,
})}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}

View File

@@ -172,7 +172,12 @@ export function ExamDetailPage() {
notify.error(
key.startsWith('insufficient_approved_questions')
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
: key,
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
max: key.split(':')[1]?.split('/')[0] ?? '',
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
})
: key,
);
}
};
@@ -188,18 +193,35 @@ export function ExamDetailPage() {
notify.error(
key.startsWith('question_not_approved')
? t('question.qc.onlyApprovedUsable')
: key,
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
max: key.split(':')[1]?.split('/')[0] ?? '',
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
})
: key,
);
}
};
const handlePrint = async () => {
const total = (exam.questions ?? []).reduce(
(s, q) => s + Number(q.points),
0,
);
if (total < Number(exam.cuttingPoint)) {
// The reachable max depends on the evaluation method, not the raw point
// sum — mirrors RecordResultModal's grading math so "can this paper pass"
// means the same thing here as it does at marking time. Cutting point can
// be raised after the paper was assembled (edit modal, no re-check on
// save), so this still needs to run even though assignment now enforces
// it too.
const questions = exam.questions ?? [];
const total = questions.reduce((s, q) => s + Number(q.points), 0);
const reachableMax =
exam.evaluationMethod === 'AVERAGE'
? questions.length
? total / questions.length
: 0
: exam.evaluationMethod === 'PERCENTAGE'
? 100
: total;
if (reachableMax < Number(exam.cuttingPoint)) {
notify.error(
`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
`This paper cannot reach the passing mark under its ${EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} evaluation (max ${reachableMax}, pass mark ${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
);
return;
}
@@ -233,7 +255,23 @@ export function ExamDetailPage() {
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ""}
${q.form === "ESSAY" ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ""}
${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("") : ""}
${
q.form === "CHOICE"
? q.options && q.options.length
? q.options
.slice()
.sort((a, b) => a.order - b.order)
.map(
(o, oi) =>
`<p style="margin: 4px 0; font-size: 13px;">${String.fromCharCode(65 + oi)}. ${o.text[locale] || o.text.en}</p>`,
)
.join("")
// No options on record (legacy question, or options relation
// wasn't loaded) — fall back to blank lines rather than
// printing nothing.
: ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("")
: ""
}
</div>`;
})
.join("");
@@ -248,7 +286,20 @@ export function ExamDetailPage() {
.header p { margin: 2px 0; font-size: 13px; color: #555; }
.directions { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; }
.directions strong { display: block; margin-bottom: 4px; }
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
.footer { margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center; }
/* Pinned to the bottom of every printed page (not just after the
last question) — @page's bottom margin leaves room for it so it
never overlaps question text on the last page. */
@media print {
@page { margin: 20mm 20mm 28mm 20mm; }
/* @page's margin already insets content from the physical page
edge — body's own 40px padding (needed on-screen, for the
preview tab before printing) would double up with it here,
wasting real page height on every side and fitting noticeably
fewer questions per page than the paper actually has room for. */
body { -webkit-print-color-adjust: exact; padding: 0; max-width: none; }
.footer { position: fixed; bottom: 0; left: 0; right: 0; margin-top: 0; }
}
</style></head><body>
<div class="header">
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
@@ -259,7 +310,7 @@ export function ExamDetailPage() {
</div>
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""}
${qHtml}
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
<div class="footer">
Generated by EMA — Ethiopian Maritime Authority
</div>
</body></html>

View File

@@ -1,5 +1,11 @@
import { ActionIcon, Group } from "@mantine/core";
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
import { ActionIcon, Menu } from "@mantine/core";
import {
IconDetails,
IconDotsVertical,
IconEdit,
IconTrash,
IconToggleRight,
} from "@tabler/icons-react";
import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
@@ -11,40 +17,55 @@ export function examActionsColumn(
onEdit: (exam: Exam) => void;
onDelete: (exam: Exam) => void;
onDetails: (exam: Exam) => void;
onOpenStatusChange: (exam: Exam) => void;
changingStatusId?: string | null;
},
): AdvancedColumn<Exam> {
return {
header: t("exam.columns.actions"),
header: t("exam.columns.actions", "Actions"),
align: "right",
cell: ({ row }) => (
<Group gap="xs">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="blue"
color="gray"
size="sm"
onClick={() => handlers.onEdit(row.original)}
loading={handlers.changingStatusId === row.original.id}
>
<IconEdit size={14} />
<IconDotsVertical size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDelete(row.original)}
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconDetails size={14} />}
onClick={() => handlers.onDetails(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
</RequirePermission>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDetails(row.original)}
>
<IconDetails size={14} />
</ActionIcon>
</Group>
{t("exam.action.details", "Details")}
</Menu.Item>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={() => handlers.onEdit(row.original)}
>
{t("exam.action.edit", "Edit")}
</Menu.Item>
<Menu.Item
leftSection={<IconToggleRight size={14} />}
onClick={() => handlers.onOpenStatusChange(row.original)}
>
{t("exam.form.status")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => handlers.onDelete(row.original)}
>
{t("exam.action.delete", "Delete")}
</Menu.Item>
</RequirePermission>
</Menu.Dropdown>
</Menu>
),
};
}

View File

@@ -76,21 +76,23 @@ function ExamForm({
editing?.cuttingPoint ?? 0,
);
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
const [activeTab, setActiveTab] = useState<string | null>("basic");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (
!certificationId ||
!titleEn ||
!titleAm ||
!date ||
!type ||
!form ||
!venue ||
!adminMethod ||
!evalMethod
) {
notify.error("Please fill all required fields");
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
setActiveTab("basic");
notify.error(t("exam.form.fillRequiredBasic"));
return;
}
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
setActiveTab("basic");
notify.error(t("exam.form.directionBothLanguages"));
return;
}
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
setActiveTab("settings");
notify.error(t("exam.form.fillRequiredSettings"));
return;
}
onSubmit(
@@ -120,7 +122,7 @@ function ExamForm({
return (
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
<form onSubmit={handleSubmit}>
<Tabs defaultValue="basic" variant="outline" radius="md">
<Tabs value={activeTab} onChange={setActiveTab} variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
{t("exam.form.basicInfo")}
@@ -251,6 +253,12 @@ function ExamForm({
onChange={setForm}
size="sm"
required
disabled={adminMethod === "ONLINE"}
description={
adminMethod === "ONLINE"
? t("exam.form.onlineChoiceOnlyHint")
: undefined
}
/>
<Select
label={t("exam.detail.administration")}
@@ -260,7 +268,14 @@ function ExamForm({
{ value: "ONLINE", label: t("exam.form.online") },
]}
value={adminMethod}
onChange={setAdminMethod}
onChange={(value) => {
setAdminMethod(value);
// Online exams are graded automatically, and that only
// has an answer model for CHOICE — matches the backend
// rule (online_exam_requires_choice_form), not just a
// UI nicety.
if (value === "ONLINE") setForm("CHOICE");
}}
size="sm"
required
/>
@@ -290,12 +305,22 @@ function ExamForm({
/>
<NumberInput
label={t("exam.form.cuttingPoint")}
placeholder={t("exam.form.cuttingPointPlaceholder")}
placeholder={
evalMethod === "PERCENTAGE"
? t("exam.form.cuttingPointPercentagePlaceholder")
: t("exam.form.cuttingPointPlaceholder")
}
value={cuttingPoint}
onChange={(v) => setCuttingPoint(Number(v))}
min={0}
max={evalMethod === "PERCENTAGE" ? 100 : undefined}
size="sm"
required
withAsterisk
description={
evalMethod === "PERCENTAGE"
? t("exam.form.cuttingPointPercentageHint")
: undefined
}
/>
</SimpleGrid>
{editing && (
@@ -352,6 +377,11 @@ export function ExamPage() {
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [changingStatusId, setChangingStatusId] = useState<string | null>(null);
const [statusTarget, setStatusTarget] = useState<Exam | null>(null);
const [pendingStatus, setPendingStatus] = useState<Exam["status"] | null>(null);
const [statusOpened, { open: openStatus, close: closeStatus }] =
useDisclosure(false);
const certOptions = certifications
.filter((c) => c.isActive)
@@ -402,6 +432,21 @@ export function ExamPage() {
}
};
const handleChangeStatus = async () => {
if (!statusTarget || !pendingStatus) return;
setChangingStatusId(statusTarget.id);
try {
await updateExam({ id: statusTarget.id, status: pendingStatus }).unwrap();
notify.success(t("exam.updated"));
closeStatus();
setStatusTarget(null);
} catch (e) {
handleError(e);
} finally {
setChangingStatusId(null);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
@@ -429,6 +474,12 @@ export function ExamPage() {
openDelete();
},
onDetails: (exam) => navigate(`/exams/${exam.id}`),
onOpenStatusChange: (exam) => {
setStatusTarget(exam);
setPendingStatus(exam.status);
openStatus();
},
changingStatusId,
}),
];
@@ -503,6 +554,47 @@ export function ExamPage() {
</Button>
</ModalFooter>
</Modal>
{/* Quick status change — not the full edit form */}
<Modal
opened={statusOpened}
onClose={closeStatus}
title={t("exam.form.status")}
size="sm"
>
<Stack gap="md">
<Text fz="sm" c="dimmed">
{statusTarget?.title?.[locale]}
</Text>
<Select
label={t("exam.form.status")}
data={[
{ value: "PENDING", label: t("exam.form.pending") },
{ value: "ACTIVE", label: t("exam.form.active") },
{ 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"])}
size="sm"
/>
<ModalFooter>
<Button variant="default" onClick={closeStatus} size="sm">
{t("exam.cancel")}
</Button>
<Button
onClick={handleChangeStatus}
size="sm"
loading={changingStatusId === statusTarget?.id}
disabled={pendingStatus === statusTarget?.status}
>
{t("exam.update")}
</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -15,11 +15,19 @@ export type ExamStatus =
| "POSTPONED"
| "PUBLISHED";
/** Only populated when the exam is fetched with `?i=questions,questions.options`. */
export interface QuestionOptionBrief {
id: string;
text: LocalePair;
order: number;
}
export interface QuestionBrief {
id: string;
title: LocalePair;
form: QuestionForm;
points: number;
options?: QuestionOptionBrief[];
}
export interface Exam {
@@ -118,8 +126,14 @@ export interface ExamRegistration {
lastName: string | null;
seafarerNumber: string | null;
};
/** The candidate's online sitting, when one has been started. */
attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
}
export type RegradeOutcome =
| { graded: true; resultId: string }
| { graded: false; reason: string };
export interface RecordAttendancePayload {
registrationId: string;
status: AttendanceStatus;

View File

@@ -1,10 +1,12 @@
import { baseApi } from '@ema-platform/api';
import type {
Question,
QuestionOption,
ListResponse,
CreateQuestionPayload,
UpdateQuestionPayload,
ReviewQuestionPayload,
SetQuestionOptionsPayload,
} from '../types/question';
const questionApi = baseApi.injectEndpoints({
@@ -17,6 +19,11 @@ const questionApi = baseApi.injectEndpoints({
query: (id) => `/questions/${id}`,
providesTags: ['Api'],
}),
/** Same question, with `options` populated — the MCQ authoring editor. */
getQuestionWithOptions: builder.query<Question, string>({
query: (id) => `/questions/${id}?i=options`,
providesTags: ['Api'],
}),
createQuestion: builder.mutation<Question, CreateQuestionPayload>({
query: (body) => ({ url: '/questions', method: 'POST', body }),
invalidatesTags: ['Api'],
@@ -47,6 +54,15 @@ const questionApi = baseApi.injectEndpoints({
}),
invalidatesTags: ['Api'],
}),
/** Full replace of a CHOICE question's options + correct-answer set (Phase 2). */
setQuestionOptions: builder.mutation<QuestionOption[], SetQuestionOptionsPayload>({
query: ({ id, ...body }) => ({
url: `/questions/${id}/options`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
@@ -54,9 +70,11 @@ const questionApi = baseApi.injectEndpoints({
export const {
useGetQuestionsQuery,
useGetQuestionQuery,
useGetQuestionWithOptionsQuery,
useCreateQuestionMutation,
useUpdateQuestionMutation,
useDeleteQuestionMutation,
useSubmitQuestionMutation,
useReviewQuestionMutation,
useSetQuestionOptionsMutation,
} = questionApi;

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react';
import { notify, useErrorHandler } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import {
useGetQuestionWithOptionsQuery,
useSetQuestionOptionsMutation,
} from '../api/question-api';
interface DraftOption {
text: BilingualValue;
isCorrect: boolean;
}
/**
* MCQ options + correct-answer editor for a CHOICE-form question (Phase 2).
*
* Only reachable while editing an already-created question — options attach
* to a question id, matching the backend's `PUT /questions/:id/options`
* full-replace endpoint. Nothing here is ever shown to a candidate; this is
* the authoring side only.
*/
export function QuestionOptionsEditor({ questionId }: { questionId: string }) {
const { t } = useTranslation();
const { handleError } = useErrorHandler();
const { data: question, isFetching } = useGetQuestionWithOptionsQuery(questionId);
const [setOptions, { isLoading: isSaving }] = useSetQuestionOptionsMutation();
const [draft, setDraft] = useState<DraftOption[]>([]);
useEffect(() => {
if (!question) return;
const existing = question.options ?? [];
setDraft(
existing.length
? existing
.slice()
.sort((a, b) => a.order - b.order)
.map((o) => ({ text: o.text, isCorrect: false }))
: [
{ text: { en: '', am: '' }, isCorrect: false },
{ text: { en: '', am: '' }, isCorrect: false },
],
);
// isCorrect never comes back from the API by design — an examiner
// re-editing options re-marks the correct one(s) rather than us
// pretending to know what they were.
}, [question]);
const updateField = (index: number, lang: keyof BilingualValue, value: string) => {
setDraft((prev) =>
prev.map((o, i) => (i === index ? { ...o, text: { ...o.text, [lang]: value } } : o)),
);
};
const toggleCorrect = (index: number) => {
setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, isCorrect: !o.isCorrect } : o)));
};
const addOption = () => {
setDraft((prev) => [...prev, { text: { en: '', am: '' }, isCorrect: false }]);
};
const removeOption = (index: number) => {
setDraft((prev) => prev.filter((_, i) => i !== index));
};
const handleSave = async () => {
if (draft.length < 2) {
notify.error(t('question.options.needAtLeastTwo'));
return;
}
if (!draft.some((o) => o.isCorrect)) {
notify.error(t('question.options.needOneCorrect'));
return;
}
if (draft.some((o) => !o.text.en.trim() || !o.text.am.trim())) {
notify.error(t('question.options.textRequired'));
return;
}
try {
await setOptions({ id: questionId, options: draft }).unwrap();
notify.success(t('question.options.saved'));
} catch (e) {
handleError(e);
}
};
if (isFetching) return <Loader size="sm" />;
return (
<Stack gap="sm">
<Alert icon={<IconInfoCircle size={15} />} color="blue" variant="light">
{t('question.options.hint')}
</Alert>
{draft.map((option, index) => (
<Group key={index} gap="xs" wrap="nowrap" align="center">
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
<Stack gap={6} style={{ flex: 1 }}>
<TextInput
label={t('question.options.optionEn', { number: index + 1 })}
value={option.text.en}
onChange={(e) => updateField(index, 'en', e.currentTarget.value)}
size="sm"
required
/>
<TextInput
label={t('question.options.optionAm', { number: index + 1 })}
value={option.text.am}
onChange={(e) => updateField(index, 'am', e.currentTarget.value)}
size="sm"
required
/>
</Stack>
<Checkbox
label={t('question.options.correct')}
checked={option.isCorrect}
onChange={() => toggleCorrect(index)}
/>
<ActionIcon
variant="subtle"
color="red"
size="sm"
disabled={draft.length <= 2}
onClick={() => removeOption(index)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
<Group justify="space-between">
<Button variant="subtle" size="xs" leftSection={<IconPlus size={14} />} onClick={addOption}>
{t('question.options.addOption')}
</Button>
<Button size="sm" loading={isSaving} onClick={handleSave}>
{t('question.options.save')}
</Button>
</Group>
<Text fz="xs" c="dimmed">{t('question.options.replaceNotice')}</Text>
</Stack>
);
}

View File

@@ -1,5 +1,11 @@
import { ActionIcon, Button, Group } from '@mantine/core';
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
import { ActionIcon, Menu } from '@mantine/core';
import {
IconDotsVertical,
IconEdit,
IconGavel,
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,52 +27,64 @@ export function questionActionsColumn(
cell: ({ row }) => {
const q = row.original;
return (
<Group gap="xs">
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
loading={handlers.isSubmittingReview}
>
<IconDotsVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<Menu.Item
leftSection={<IconSend size={14} />}
onClick={() => handlers.onSubmitForApproval(q)}
>
{t('question.qc.submit')}
</Menu.Item>
</RequirePermission>
)}
{q.status === 'PENDING_APPROVAL' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Menu.Item color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
{t('question.qc.approve')}
</Menu.Item>
<Menu.Item color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
{t('question.qc.reject')}
</Menu.Item>
</RequirePermission>
)}
{q.status === 'APPROVED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Menu.Item
color="dark"
leftSection={<IconGavel size={14} />}
onClick={() => handlers.onReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Menu.Item>
</RequirePermission>
)}
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<Button
size="compact-xs"
variant="light"
leftSection={<IconSend size={12} />}
loading={handlers.isSubmittingReview}
onClick={() => handlers.onSubmitForApproval(q)}
<Menu.Divider />
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => handlers.onEdit(q)}>
{t('question.action.edit', 'Edit')}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => handlers.onDelete(q)}
>
{t('question.qc.submit')}
</Button>
{t('question.action.delete', 'Delete')}
</Menu.Item>
</RequirePermission>
)}
{q.status === 'PENDING_APPROVAL' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
{t('question.qc.approve')}
</Button>
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
{t('question.qc.reject')}
</Button>
</RequirePermission>
)}
{q.status === 'APPROVED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Button
size="compact-xs"
variant="subtle"
color="dark"
leftSection={<IconGavel size={12} />}
onClick={() => handlers.onReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Button>
</RequirePermission>
)}
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
<IconTrash size={14} />
</ActionIcon>
</RequirePermission>
</Group>
</Menu.Dropdown>
</Menu>
);
},
};

View File

@@ -1,12 +1,42 @@
import { useState } from 'react';
import {Stack, Group, Button, Badge, Modal, Text, TextInput, Select, NumberInput, Textarea} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import {IconPlus} from '@tabler/icons-react';
import { AdvancedColumn, AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
import { useState } from "react";
import {
Stack,
Title,
Group,
Button,
Badge,
Modal,
Text,
TextInput,
Card,
Alert,
Select,
NumberInput,
Textarea,
Checkbox,
ActionIcon,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import {
IconPlus,
IconInfoCircle,
IconTrash,
IconGripVertical,
} from "@tabler/icons-react";
import {
AdvancedColumn,
AdvancedTable,
ErrorState,
ModalFooter,
notify,
PageHeader,
useErrorHandler,
useServerTable,
} from "@ema-platform/ui";
import { extractErrorMessage } from "@ema-platform/api";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
import {
useGetQuestionsQuery,
useCreateQuestionMutation,
@@ -14,10 +44,92 @@ import {
useDeleteQuestionMutation,
useSubmitQuestionMutation,
useReviewQuestionMutation,
} from '../../api/question-api';
import type { Question, QuestionForm } from '../../types/question';
import { questionColumns } from './columns';
import { questionActionsColumn } from './actions';
useSetQuestionOptionsMutation,
} from "../../api/question-api";
import type {
Question,
QuestionForm,
QuestionOptionInput,
} from "../../types/question";
import { QuestionOptionsEditor } from "../../components/QuestionOptionsEditor";
import { questionColumns } from "./columns";
import { questionActionsColumn } from "./actions";
type DraftOption = { textEn: string; textAm: string; isCorrect: boolean };
const BLANK_DRAFT_OPTIONS: DraftOption[] = [
{ textEn: "", textAm: "", isCorrect: false },
{ textEn: "", textAm: "", isCorrect: false },
];
/**
* Options for a brand-new CHOICE question, entered inline in the same
* modal — no question id exists yet, so this is pure local state, only
* turned into a real setOptions() call once the question itself is
* created (see QuestionPage.handleSubmit).
*/
function InlineOptionsEditor({
options,
onChange,
}: {
options: DraftOption[];
onChange: (options: DraftOption[]) => void;
}) {
const { t } = useTranslation();
const update = (index: number, patch: Partial<DraftOption>) =>
onChange(options.map((o, i) => (i === index ? { ...o, ...patch } : o)));
return (
<Stack gap="xs">
{options.map((option, index) => (
<Group key={index} gap="xs" wrap="nowrap" align="center">
<IconGripVertical size={16} style={{ opacity: 0.4 }} />
<Stack gap={6} style={{ flex: 1 }}>
<TextInput
label={t("question.options.optionEn", { number: index + 1 })}
value={option.textEn}
onChange={(e) => update(index, { textEn: e.currentTarget.value })}
size="sm"
required
/>
<TextInput
label={t("question.options.optionAm", { number: index + 1 })}
value={option.textAm}
onChange={(e) => update(index, { textAm: e.currentTarget.value })}
size="sm"
required
/>
</Stack>
<Checkbox
label={t("question.options.correct")}
checked={option.isCorrect}
onChange={() => update(index, { isCorrect: !option.isCorrect })}
/>
<ActionIcon
variant="subtle"
color="red"
size="sm"
disabled={options.length <= 2}
onClick={() => onChange(options.filter((_, i) => i !== index))}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
<Button
variant="subtle"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
onChange([...options, { textEn: "", textAm: "", isCorrect: false }])
}
>
{t("question.options.addOption")}
</Button>
</Stack>
);
}
function QuestionForm({
editing,
@@ -29,58 +141,181 @@ function QuestionForm({
editing: Question | null;
certOptions: { value: string; label: string }[];
isSubmitting: boolean;
onSubmit: (values: {
certificationId: string;
titleEn: string;
titleAm: string;
form: string;
points: number;
days: number;
hours: number;
minutes: number;
}, isEdit: boolean) => void;
onSubmit: (
values: {
certificationId: string;
titleEn: string;
titleAm: string;
form: string;
points: number;
days: number;
hours: number;
minutes: number;
draftOptions: DraftOption[];
},
isEdit: boolean,
) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
const [certificationId, setCertificationId] = useState<string | null>(
editing?.certificationId ?? null,
);
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
const [form, setForm] = useState<string | null>(editing?.form ?? null);
const [points, setPoints] = useState<number>(editing?.points ?? 0);
const [days, setDays] = useState(editing?.time?.days ?? 0);
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
const [draftOptions, setDraftOptions] =
useState<DraftOption[]>(BLANK_DRAFT_OPTIONS);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!certificationId || !titleEn || !titleAm || !form) {
notify.error('Please fill all required fields');
notify.error("Please fill all required fields");
return;
}
onSubmit({
certificationId, titleEn, titleAm, form, points, days, hours, minutes
}, !!editing);
if (!editing && form === "CHOICE") {
if (draftOptions.length < 2) {
notify.error(t("question.options.needAtLeastTwo"));
return;
}
if (!draftOptions.some((o) => o.isCorrect)) {
notify.error(t("question.options.needOneCorrect"));
return;
}
if (draftOptions.some((o) => !o.textEn.trim() || !o.textAm.trim())) {
notify.error(t("question.options.textRequired"));
return;
}
}
onSubmit(
{
certificationId,
titleEn,
titleAm,
form,
points,
days,
hours,
minutes,
draftOptions: !editing && form === "CHOICE" ? draftOptions : [],
},
!!editing,
);
};
return (
<Modal opened onClose={onCancel} title={editing ? t('question.update') : t('question.addQuestion')} size="lg">
<Modal
opened
onClose={onCancel}
title={editing ? t("question.update") : t("question.addQuestion")}
size="lg"
>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
<TextInput label={t('question.form.titleEn')} placeholder={t('question.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
<TextInput label={t('question.form.titleAm')} placeholder={t('question.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
<Select label={t('question.form.form')} placeholder={t('question.form.selectForm')} data={[{ value: 'ESSAY', label: t('question.form.essay') }, { value: 'CHOICE', label: t('question.form.choice') }]} value={form} onChange={setForm} size="sm" required />
<NumberInput label={t('question.form.points')} placeholder={t('question.form.pointsPlaceholder')} value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required />
<Text fz="sm" fw={500}>{t('question.form.timeAllowed')}</Text>
<Select
label={t("question.form.certification")}
placeholder={t("question.form.selectCertification")}
data={certOptions}
value={certificationId}
onChange={setCertificationId}
size="sm"
searchable
required
/>
<TextInput
label={t("question.form.titleEn")}
placeholder={t("question.form.titleEnPlaceholder")}
value={titleEn}
onChange={(e) => setTitleEn(e.currentTarget.value)}
size="sm"
required
/>
<TextInput
label={t("question.form.titleAm")}
placeholder={t("question.form.titleAmPlaceholder")}
value={titleAm}
onChange={(e) => setTitleAm(e.currentTarget.value)}
size="sm"
required
/>
<Select
label={t("question.form.form")}
placeholder={t("question.form.selectForm")}
data={[
{ value: "ESSAY", label: t("question.form.essay") },
{ value: "CHOICE", label: t("question.form.choice") },
]}
value={form}
onChange={setForm}
size="sm"
required
/>
<NumberInput
label={t("question.form.points")}
placeholder={t("question.form.pointsPlaceholder")}
value={points}
onChange={(v) => setPoints(Number(v))}
min={0}
size="sm"
required
/>
<Text fz="sm" fw={500}>
{t("question.form.timeAllowed")}
</Text>
<Group gap="sm" grow>
<NumberInput label={t('question.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
<NumberInput
label={t("question.form.days")}
value={days}
onChange={(v) => setDays(Number(v))}
min={0}
size="sm"
/>
<NumberInput
label={t("question.form.hours")}
value={hours}
onChange={(v) => setHours(Number(v))}
min={0}
size="sm"
/>
<NumberInput
label={t("question.form.minutes")}
value={minutes}
onChange={(v) => setMinutes(Number(v))}
min={0}
size="sm"
/>
</Group>
{editing && form === "CHOICE" && (
<>
<Text fz="sm" fw={500} mt="sm">
{t("question.options.title")}
</Text>
<QuestionOptionsEditor questionId={editing.id} />
</>
)}
{!editing && form === "CHOICE" && (
<>
<Text fz="sm" fw={500} mt="sm">
{t("question.options.title")}
</Text>
<InlineOptionsEditor
options={draftOptions}
onChange={setDraftOptions}
/>
</>
)}
<ModalFooter>
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
<Button variant="default" onClick={onCancel} size="sm">
{t("question.cancel")}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editing ? t("question.update") : t("question.create")}
</Button>
</ModalFooter>
</Stack>
</form>
@@ -90,7 +325,7 @@ function QuestionForm({
export function QuestionPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const locale = i18n.language as "en" | "am";
const { handleError } = useErrorHandler();
const { data: certRes } = useGetCertificationsQuery();
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
@@ -98,7 +333,10 @@ export function QuestionPage() {
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
const [deleteQ] = useDeleteQuestionMutation();
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
const [setOptions, { isLoading: isSavingOptions }] =
useSetQuestionOptionsMutation();
const [submitQ, { isLoading: isSubmittingReview }] =
useSubmitQuestionMutation();
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
const certifications = certRes?.items ?? [];
@@ -107,60 +345,115 @@ export function QuestionPage() {
const [editing, setEditing] = useState<Question | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [deleteOpened, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [certFilter, setCertFilter] = useState<string | null>(null);
const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED');
const [reviewRemark, setReviewRemark] = useState('');
const [reviewOutcome, setReviewOutcome] = useState<
"APPROVED" | "REJECTED" | "RETIRED"
>("APPROVED");
const [reviewRemark, setReviewRemark] = useState("");
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
const certOptions = certifications
.filter((c) => c.isActive)
.map((c) => ({ value: c.id, label: c.name[locale] }));
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
const filtered = questions.filter(
(q) => !certFilter || q.certificationId === certFilter,
);
const page = paginate(filtered);
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
const getCertName = (id: string) =>
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
const resetForm = () => { setEditing(null); setShowForm(false); };
const resetForm = () => {
setEditing(null);
setShowForm(false);
};
const handleSubmit = async (values: {
certificationId: string; titleEn: string; titleAm: string;
form: string; points: number; days: number; hours: number; minutes: number;
}, isEdit: boolean) => {
const handleSubmit = async (
values: {
certificationId: string;
titleEn: string;
titleAm: string;
form: string;
points: number;
days: number;
hours: number;
minutes: number;
draftOptions: DraftOption[];
},
isEdit: boolean,
) => {
const title = { en: values.titleEn, am: values.titleAm };
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
const time = {
days: values.days,
hours: values.hours,
minutes: values.minutes,
};
try {
if (isEdit && editing) {
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success(t('question.updated'));
await updateQ({
id: editing.id,
certificationId: values.certificationId,
title,
form: values.form as QuestionForm,
points: values.points,
time,
}).unwrap();
notify.success(t("question.updated"));
} else {
await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success(t('question.created'));
const created = await createQ({
certificationId: values.certificationId,
title,
description: { en: "", am: "" },
form: values.form as QuestionForm,
points: values.points,
time,
}).unwrap();
// The question needs an id to attach options to — this is the second
// half of one "create" action from the user's point of view, not a
// separate edit step, so it happens right here rather than waiting
// for them to reopen the question later.
if (values.form === "CHOICE" && values.draftOptions.length) {
const options: QuestionOptionInput[] = values.draftOptions.map(
(o) => ({
text: { en: o.textEn, am: o.textAm },
isCorrect: o.isCorrect,
}),
);
await setOptions({ id: created.id, options }).unwrap();
}
notify.success(t("question.created"));
}
resetForm();
} catch {
notify.error(t('question.error'));
notify.error(t("question.error"));
}
};
const handleSubmitForApproval = async (question: Question) => {
try {
await submitQ(question.id).unwrap();
notify.success(t('question.qc.submitted'));
notify.success(t("question.qc.submitted"));
} catch (error) {
notify.error(extractErrorMessage(error, t('question.qc.error')));
notify.error(extractErrorMessage(error, t("question.qc.error")));
}
};
const openReview = (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => {
const openReview = (
question: Question,
outcome: "APPROVED" | "REJECTED" | "RETIRED",
) => {
setReviewTarget(question);
setReviewOutcome(outcome);
setReviewRemark('');
setReviewRemark("");
};
const handleReview = async () => {
if (!reviewTarget) return;
if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) {
notify.error(t('question.qc.remarkRequired'));
if (reviewOutcome !== "APPROVED" && !reviewRemark.trim()) {
notify.error(t("question.qc.remarkRequired"));
return;
}
try {
@@ -169,10 +462,10 @@ export function QuestionPage() {
outcome: reviewOutcome,
remark: reviewRemark.trim() || undefined,
}).unwrap();
notify.success(t('question.qc.reviewed'));
notify.success(t("question.qc.reviewed"));
setReviewTarget(null);
} catch (error) {
notify.error(extractErrorMessage(error, t('question.qc.error')));
notify.error(extractErrorMessage(error, t("question.qc.error")));
}
};
@@ -180,7 +473,7 @@ export function QuestionPage() {
if (!deleteTarget) return;
try {
await deleteQ(deleteTarget.id).unwrap();
notify.success(t('question.deleted'));
notify.success(t("question.deleted"));
closeDelete();
setDeleteTarget(null);
} catch (e) {
@@ -188,7 +481,8 @@ export function QuestionPage() {
}
};
if (isError) return <ErrorState title={t('question.loadError')} onRetry={refetch} />;
if (isError)
return <ErrorState title={t("question.loadError")} onRetry={refetch} />;
const columns: AdvancedColumn<Question>[] = [
...questionColumns(t, { locale, getCertName }),
@@ -196,21 +490,35 @@ export function QuestionPage() {
isSubmittingReview,
onSubmitForApproval: handleSubmitForApproval,
onReview: openReview,
onEdit: (q) => { setEditing(q); setShowForm(true); },
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
onEdit: (q) => {
setEditing(q);
setShowForm(true);
},
onDelete: (q) => {
setDeleteTarget(q);
openDelete();
},
}),
];
return (
<Stack gap="lg">
<PageHeader
title={t('question.title')}
title={t("question.title")}
noMargin
action={
!showForm && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('question.addQuestion')}
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]}
hideOnly
>
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
size="sm"
>
{t("question.addQuestion")}
</Button>
</RequirePermission>
)
@@ -221,7 +529,7 @@ export function QuestionPage() {
<QuestionForm
editing={editing}
certOptions={certOptions}
isSubmitting={isCreating || isUpdating}
isSubmitting={isCreating || isUpdating || isSavingOptions}
onSubmit={handleSubmit}
onCancel={resetForm}
/>
@@ -230,66 +538,98 @@ export function QuestionPage() {
<AdvancedTable
columns={columns}
data={page.rows}
title={t('question.pool')}
tableName={t('question.title')}
title={t("question.pool")}
tableName={t("question.title")}
toolbar={
<Select
placeholder={t('question.filterByCertification')}
data={[{ value: '', label: 'All' }, ...certOptions]}
placeholder={t("question.filterByCertification")}
data={[{ value: "", label: "All" }, ...certOptions]}
value={certFilter}
onChange={(v) => { setCertFilter(v ?? null); setPageIndex(0); }}
onChange={(v) => {
setCertFilter(v ?? null);
setPageIndex(0);
}}
size="sm"
style={{ width: 280 }}
clearable
/>
}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('question.noQuestions')}
/>
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t("question.noQuestions")}
/>
<Modal
opened={Boolean(reviewTarget)}
onClose={() => setReviewTarget(null)}
title={t('question.qc.reviewTitle')}
title={t("question.qc.reviewTitle")}
size="md"
radius="lg"
>
<Stack gap="sm">
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text>
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text>
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content">
<Text fz="sm" fw={500}>
{reviewTarget?.title?.[locale]}
</Text>
<Text fz="xs" c="dimmed">
{t("question.qc.onlyApprovedUsable")}
</Text>
<Badge
variant="light"
color={
reviewOutcome === "APPROVED"
? "teal"
: reviewOutcome === "REJECTED"
? "red"
: "dark"
}
w="fit-content"
>
{t(`question.qc.${reviewOutcome}`)}
</Badge>
<Textarea
label={t('question.qc.remark')}
label={t("question.qc.remark")}
minRows={3}
autosize
value={reviewRemark}
onChange={(e) => setReviewRemark(e.currentTarget.value)}
required={reviewOutcome !== 'APPROVED'}
required={reviewOutcome !== "APPROVED"}
/>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setReviewTarget(null)}>
{t('question.cancel')}
<Button
variant="default"
size="sm"
onClick={() => setReviewTarget(null)}
>
{t("question.cancel")}
</Button>
<Button size="sm" loading={isReviewing} onClick={handleReview}>
{t(`question.qc.${reviewOutcome === 'APPROVED' ? 'approve' : reviewOutcome === 'REJECTED' ? 'reject' : 'retire'}`)}
{t(
`question.qc.${reviewOutcome === "APPROVED" ? "approve" : reviewOutcome === "REJECTED" ? "reject" : "retire"}`,
)}
</Button>
</Group>
</Stack>
</Modal>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
<Text mb="md">{t('question.deleteConfirmText')}</Text>
<Modal
opened={deleteOpened}
onClose={closeDelete}
title={t("question.confirmDelete")}
size="sm"
>
<Text mb="md">{t("question.deleteConfirmText")}</Text>
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
<Button variant="default" onClick={closeDelete} size="sm">
{t("question.cancel")}
</Button>
<Button color="red" onClick={handleDelete} size="sm">
{t("question.delete")}
</Button>
</ModalFooter>
</Modal>
</Stack>

View File

@@ -16,6 +16,17 @@ export type QuestionStatus =
| 'REJECTED'
| 'RETIRED';
/**
* A CHOICE option, as returned by the authoring/QC endpoints. Never carries
* a correctness flag — the API's own answer-key table is never joined into
* this response either, so there's nothing to accidentally serialize here.
*/
export interface QuestionOption {
id: string;
text: LocalePair;
order: number;
}
export interface Question {
id: string;
certificationId: string;
@@ -32,6 +43,8 @@ export interface Question {
submittedAt: string | null;
createdAt: string;
updatedAt: string;
/** Only populated when explicitly requested (`?i=options`). */
options?: QuestionOption[];
}
export interface ReviewQuestionPayload {
@@ -64,3 +77,13 @@ export interface UpdateQuestionPayload {
points?: number;
isActive?: boolean;
}
export interface QuestionOptionInput {
text: LocalePair;
isCorrect: boolean;
}
export interface SetQuestionOptionsPayload {
id: string;
options: QuestionOptionInput[];
}

View File

@@ -1,5 +1,5 @@
import { Button, Group } from '@mantine/core';
import { IconEye, IconTrash } from '@tabler/icons-react';
import { ActionIcon, Menu } from '@mantine/core';
import { IconDotsVertical, IconEye, 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';
@@ -13,6 +13,7 @@ export function resultActionsColumn(
onQc: (result: Result, action: QcAction) => void;
onViewDetail: (result: Result) => void;
onDelete: (result: Result) => void;
onPublish: (result: Result) => void;
},
): AdvancedColumn<Result> {
return {
@@ -21,49 +22,66 @@ export function resultActionsColumn(
cell: ({ row }) => {
const r = row.original;
return (
<Group gap="xs">
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
<>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
{t('result.review.moderate')}
</Button>
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconDotsVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => handlers.onViewDetail(r)}>
{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>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Menu.Item color="blue" onClick={() => handlers.onQc(r, 'approve')}>
{t('result.review.approve')}
</Menu.Item>
</RequirePermission>
</>
)}
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<RequirePermission
anyOf={[
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT,
]}
hideOnly
>
<Menu.Item color="orange" onClick={() => handlers.onQc(r, 'return')}>
{t('result.review.return')}
</Menu.Item>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
{t('result.review.approve')}
</Button>
)}
{r.reviewStatus === 'APPROVED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
<Menu.Item
color="teal"
leftSection={<IconSend size={14} />}
onClick={() => handlers.onPublish(r)}
>
{t('result.review.publish')}
</Menu.Item>
</RequirePermission>
</>
)}
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<RequirePermission
anyOf={[
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT,
]}
hideOnly
>
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
{t('result.review.return')}
</Button>
)}
<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>
)}
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
{t('result.action.viewEdit')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => handlers.onDelete(r)}
>
{t('result.action.delete')}
</Button>
</RequirePermission>
</Group>
</Menu.Dropdown>
</Menu>
);
},
};

View File

@@ -95,6 +95,8 @@ export function ResultPage() {
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [publishTarget, setPublishTarget] = useState<Result | null>(null);
const [publishOpened, { open: openPublish, close: closePublish }] = useDisclosure(false);
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
const [detailSaving, setDetailSaving] = useState(false);
@@ -231,6 +233,19 @@ export function ResultPage() {
}
};
/** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */
const handleConfirmPublish = async () => {
if (!publishTarget) return;
try {
const outcome = await publishResults(publishTarget.examId).unwrap();
notify.success(t('result.review.publishedCount', outcome));
closePublish();
setPublishTarget(null);
} catch (error) {
notify.error(extractErrorMessage(error, t('result.review.error')));
}
};
const handleDetailClose = () => {
closeDetail();
setDetailBreakdowns([]);
@@ -257,6 +272,7 @@ export function ResultPage() {
onQc: openQc,
onViewDetail: viewDetail,
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
onPublish: (r) => { setPublishTarget(r); openPublish(); },
}),
];
@@ -283,9 +299,11 @@ export function ResultPage() {
{t('result.review.publish')}
</Button>
</RequirePermission>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')}
</Button>
</RequirePermission>
</Group>
}
/>
@@ -472,14 +490,22 @@ export function ResultPage() {
<ModalFooter>
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
<Button
onClick={handleDetailSave}
size="sm"
loading={detailSaving}
leftSection={<IconDeviceFloppy size={15} />}
<RequirePermission
anyOf={[
LICENSE_PERMISSIONS.RECORD_EXAM_RESULT,
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
]}
hideOnly
>
{t('result.save')}
</Button>
<Button
onClick={handleDetailSave}
size="sm"
loading={detailSaving}
leftSection={<IconDeviceFloppy size={15} />}
>
{t('result.save')}
</Button>
</RequirePermission>
</ModalFooter>
</Stack>
) : (
@@ -542,6 +568,20 @@ export function ResultPage() {
</ModalFooter>
</Modal>
<Modal opened={publishOpened} onClose={closePublish} title={t('result.review.publish')} size="sm">
<Text mb="md">
{t('result.review.publishConfirmText', {
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>
</ModalFooter>
</Modal>
{/* Choose exam, then record */}
<Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg">
<Stack gap="md">

View File

@@ -259,6 +259,7 @@ export const am: Translations = {
choice: "ምርጫ",
offline: "ከመስመር ውጪ",
online: "በመስመር",
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
sum: "ድምር",
average: "አማካይ",
percentage: "መቶኛ",
@@ -266,6 +267,11 @@ export const am: Translations = {
random: "በዘፈቀደ",
cuttingPoint: "የማለፊያ ነጥብ",
cuttingPointPlaceholder: "ለማለፍ ዝቅተኛ ነጥብ",
cuttingPointPercentagePlaceholder: "ለማለፍ ዝቅተኛ መቶኛ (0-100)",
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
status: "ሁኔታ",
statusPlaceholder: "የፈተና ሁኔታ",
pending: "በመጠባበቅ ላይ",
@@ -330,6 +336,10 @@ export const am: Translations = {
retake: "ድጋሚ {{n}}",
firstSitting: "የመጀመሪያ ሙከራ",
remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።",
regrade: "እንደገና ደረጃ ስጥ",
regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።",
regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።",
regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።",
},
attendance: {
REGISTERED: "አልተጠራም",
@@ -374,6 +384,8 @@ export const am: Translations = {
randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል",
randomError: "ጥያቄዎችን መምረጥ አልተቻለም",
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
cannotReachCuttingPoint:
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
},
country: {
@@ -691,6 +703,8 @@ export const am: Translations = {
returned: "ውጤት ወደ ፈታኙ ተመልሷል",
publishedCount: "{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።",
publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።",
publishConfirmText:
"ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?",
lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።",
originalScore: "የፈታኙ ጠቅላላ",
derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",
@@ -782,6 +796,22 @@ export const am: Translations = {
onlyApprovedUsable: "የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።",
error: "ተግባሩ አልተሳካም",
},
options: {
title: "የመልስ አማራጮች",
hint: "ትክክለኛውን አማራጭ ምረጥ/ምረጪ። ማስቀመጥ መላውን የአማራጭ ስብስብ ይተካል።",
optionLabel: "አማራጭ {{number}}",
optionEn: "አማራጭ {{number}} (እንግሊዝኛ)",
optionAm: "አማራጭ {{number}} (አማርኛ)",
correct: "ትክክለኛ",
addOption: "አማራጭ ጨምር",
save: "አማራጮችን አስቀምጥ",
saved: "አማራጮች ተቀምጠዋል",
saveFirst: "መጀመሪያ ጥያቄውን አስቀምጥ፣ ከዚያ አማራጮችን ጨምር።",
replaceNotice: "ትክክለኛ መልሶች ከተቀመጡ በኋላ እዚህ አይታዩም — እንደገና ካስተካከልክ/ካስተካከልሽ ዳግም ምረጥ/ምረጪ።",
needAtLeastTwo: "ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልገዋል።",
needOneCorrect: "ቢያንስ አንድ አማራጭ እንደ ትክክለኛ ምረጥ/ምረጪ።",
textRequired: "እያንዳንዱ አማራጭ በሁለቱም ቋንቋዎች ጽሑፍ ያስፈልገዋል።",
},
},
configuration: {

View File

@@ -258,6 +258,7 @@ export const en = {
choice: 'Choice',
offline: 'Offline',
online: 'Online',
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
sum: 'Sum',
average: 'Average',
percentage: 'Percentage',
@@ -265,6 +266,11 @@ export const en = {
random: 'Random',
cuttingPoint: 'Cutting Point (Pass Mark)',
cuttingPointPlaceholder: 'Minimum score to pass',
cuttingPointPercentagePlaceholder: 'Minimum % to pass (0-100)',
cuttingPointPercentageHint: 'Percentage evaluation — capped at 100.',
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
status: 'Status',
statusPlaceholder: 'Exam status',
pending: 'Pending',
@@ -328,6 +334,10 @@ export const en = {
retake: 'Retake {{n}}',
firstSitting: 'First sitting',
remarkRequired: 'A reason is required for a withdrawal or a disqualification.',
regrade: 'Regrade',
regraded: 'Result created from the graded attempt.',
regradeNotEligible: 'Not auto-gradable: {{reason}}. Use Record Result instead.',
regradeError: 'Could not regrade this attempt.',
},
attendance: {
REGISTERED: 'Not called',
@@ -373,6 +383,8 @@ export const en = {
randomError: 'Could not draw questions',
notEnoughApproved:
'Not enough approved questions in the bank for this subject.',
cannotReachCuttingPoint:
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
},
country: {
@@ -692,6 +704,8 @@ export const en = {
returned: 'Result returned to the examiner',
publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.',
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?',
lockedAfterApproval:
'This result is approved and can no longer be edited. Return it to the examiner first.',
originalScore: 'Examiner total',
@@ -785,6 +799,23 @@ export const en = {
'Only approved items can be placed on an examination paper.',
error: 'Operation failed',
},
options: {
title: 'Answer Options',
hint: 'Mark every correct option. Saving replaces the entire option set.',
optionLabel: 'Option {{number}}',
optionEn: 'Option {{number}} (English)',
optionAm: 'Option {{number}} (Amharic)',
correct: 'Correct',
addOption: 'Add option',
save: 'Save options',
saved: 'Options saved',
saveFirst: 'Save the question first, then add its options.',
replaceNotice:
'Correct answers are never shown here once saved — re-mark them if you edit this set again.',
needAtLeastTwo: 'A question needs at least two options.',
needOneCorrect: 'Mark at least one option as correct.',
textRequired: 'Every option needs text in both languages.',
},
},
configuration: {