mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user