Merge pull request #20 from Tria-plc/feature/exam-attempt-domain

Feature/exam attempt domain
This commit is contained in:
Nati Nigussie
2026-08-21 21:08:13 +03:00
committed by GitHub
33 changed files with 2365 additions and 210 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 }) => (
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
>
<Button
size="compact-xs"
variant="light"
leftSection={<IconUserCheck size={12} />}
<Menu.Item
leftSection={<IconUserCheck size={14} />}
onClick={() => handlers.onRecord(row.original)}
>
{t('exam.candidates.record')}
</Button>
</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

@@ -171,6 +171,11 @@ export function ExamDetailPage() {
notify.error(
key.startsWith('insufficient_approved_questions')
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
max: key.split(':')[1]?.split('/')[0] ?? '',
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
})
: key,
);
}
@@ -187,18 +192,35 @@ export function ExamDetailPage() {
notify.error(
key.startsWith('question_not_approved')
? t('question.qc.onlyApprovedUsable')
: 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;
}
@@ -232,7 +254,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("");
@@ -247,7 +285,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" />` : ""}
@@ -258,7 +309,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)}
>
<IconTrash size={14} />
</ActionIcon>
</RequirePermission>
<ActionIcon
variant="subtle"
color="red"
size="sm"
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconDetails size={14} />}
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

@@ -77,21 +77,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(
@@ -121,7 +123,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")}
@@ -252,6 +254,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")}
@@ -261,7 +269,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
/>
@@ -291,12 +306,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 && (
@@ -353,6 +378,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)
@@ -403,6 +433,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 {
@@ -436,6 +481,12 @@ export function ExamPage() {
openDelete();
},
onDetails: (exam) => navigate(`/exams/${exam.id}`),
onOpenStatusChange: (exam) => {
setStatusTarget(exam);
setPendingStatus(exam.status);
openStatus();
},
changingStatusId,
}),
];
@@ -511,6 +562,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">
<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>
<Button
size="compact-xs"
variant="light"
leftSection={<IconSend size={12} />}
loading={handlers.isSubmittingReview}
<Menu.Item
leftSection={<IconSend size={14} />}
onClick={() => handlers.onSubmitForApproval(q)}
>
{t('question.qc.submit')}
</Button>
</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')}>
<Menu.Item 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')}>
</Menu.Item>
<Menu.Item color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
{t('question.qc.reject')}
</Button>
</Menu.Item>
</RequirePermission>
)}
{q.status === 'APPROVED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
<Button
size="compact-xs"
variant="subtle"
<Menu.Item
color="dark"
leftSection={<IconGavel size={12} />}
leftSection={<IconGavel size={14} />}
onClick={() => handlers.onReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Button>
</Menu.Item>
</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>
<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.action.delete', 'Delete')}
</Menu.Item>
</RequirePermission>
</Group>
</Menu.Dropdown>
</Menu>
);
},
};

View File

@@ -13,10 +13,12 @@ import {
Select,
NumberInput,
Textarea,
Checkbox,
ActionIcon,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { IconPlus, IconInfoCircle, IconTrash, IconGripVertical } from '@tabler/icons-react';
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -28,11 +30,87 @@ import {
useDeleteQuestionMutation,
useSubmitQuestionMutation,
useReviewQuestionMutation,
useSetQuestionOptionsMutation,
} from '../../api/question-api';
import type { Question, QuestionForm } from '../../types/question';
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,
certOptions,
@@ -52,6 +130,7 @@ function QuestionForm({
days: number;
hours: number;
minutes: number;
draftOptions: DraftOption[];
}, isEdit: boolean) => void;
onCancel: () => void;
}) {
@@ -65,6 +144,7 @@ function QuestionForm({
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();
@@ -72,8 +152,23 @@ function QuestionForm({
notify.error('Please fill all required fields');
return;
}
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
certificationId, titleEn, titleAm, form, points, days, hours, minutes,
draftOptions: !editing && form === 'CHOICE' ? draftOptions : [],
}, !!editing);
};
@@ -92,6 +187,18 @@ function QuestionForm({
<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>
@@ -112,6 +219,7 @@ export function QuestionPage() {
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
const [deleteQ] = useDeleteQuestionMutation();
const [setOptions, { isLoading: isSavingOptions }] = useSetQuestionOptionsMutation();
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
@@ -139,6 +247,7 @@ export function QuestionPage() {
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 };
@@ -147,7 +256,18 @@ export function QuestionPage() {
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();
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();
@@ -232,7 +352,7 @@ export function QuestionPage() {
<QuestionForm
editing={editing}
certOptions={certOptions}
isSubmitting={isCreating || isUpdating}
isSubmitting={isCreating || isUpdating || isSavingOptions}
onSubmit={handleSubmit}
onCancel={resetForm}
/>

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,18 +22,27 @@ export function resultActionsColumn(
cell: ({ row }) => {
const r = row.original;
return (
<Group gap="xs">
<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>
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
<Menu.Item color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
{t('result.review.moderate')}
</Button>
</Menu.Item>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
<Menu.Item color="blue" onClick={() => handlers.onQc(r, 'approve')}>
{t('result.review.approve')}
</Button>
</Menu.Item>
</RequirePermission>
</>
)}
@@ -44,26 +54,34 @@ export function resultActionsColumn(
]}
hideOnly
>
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
<Menu.Item color="orange" onClick={() => handlers.onQc(r, 'return')}>
{t('result.review.return')}
</Button>
</Menu.Item>
</RequirePermission>
)}
{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>
)}
<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"
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash size={13} />}
leftSection={<IconTrash size={14} />}
onClick={() => handlers.onDelete(r)}
>
{t('result.action.delete')}
</Button>
</Menu.Item>
</RequirePermission>
</Group>
</Menu.Dropdown>
</Menu>
);
},
};

View File

@@ -126,6 +126,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);
@@ -262,6 +264,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([]);
@@ -288,6 +303,7 @@ export function ResultPage() {
onQc: openQc,
onViewDetail: viewDetail,
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
onPublish: (r) => { setPublishTarget(r); openPublish(); },
}),
];
@@ -314,9 +330,11 @@ export function ResultPage() {
{t('result.review.publish')}
</Button>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')}
</Button>
</RequirePermission>
</Group>
</Group>
@@ -504,6 +522,13 @@ export function ResultPage() {
<ModalFooter>
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
<RequirePermission
anyOf={[
LICENSE_PERMISSIONS.RECORD_EXAM_RESULT,
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
]}
hideOnly
>
<Button
onClick={handleDetailSave}
size="sm"
@@ -512,6 +537,7 @@ export function ResultPage() {
>
{t('result.save')}
</Button>
</RequirePermission>
</ModalFooter>
</Stack>
) : (
@@ -574,6 +600,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

@@ -255,6 +255,7 @@ export const am: Translations = {
choice: "ምርጫ",
offline: "ከመስመር ውጪ",
online: "በመስመር",
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
sum: "ድምር",
average: "አማካይ",
percentage: "መቶኛ",
@@ -262,6 +263,11 @@ export const am: Translations = {
random: "በዘፈቀደ",
cuttingPoint: "የማለፊያ ነጥብ",
cuttingPointPlaceholder: "ለማለፍ ዝቅተኛ ነጥብ",
cuttingPointPercentagePlaceholder: "ለማለፍ ዝቅተኛ መቶኛ (0-100)",
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
status: "ሁኔታ",
statusPlaceholder: "የፈተና ሁኔታ",
pending: "በመጠባበቅ ላይ",
@@ -326,6 +332,10 @@ export const am: Translations = {
retake: "ድጋሚ {{n}}",
firstSitting: "የመጀመሪያ ሙከራ",
remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።",
regrade: "እንደገና ደረጃ ስጥ",
regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።",
regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።",
regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።",
},
attendance: {
REGISTERED: "አልተጠራም",
@@ -370,6 +380,8 @@ export const am: Translations = {
randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል",
randomError: "ጥያቄዎችን መምረጥ አልተቻለም",
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
cannotReachCuttingPoint:
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
},
country: {
@@ -687,6 +699,8 @@ export const am: Translations = {
returned: "ውጤት ወደ ፈታኙ ተመልሷል",
publishedCount: "{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።",
publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።",
publishConfirmText:
"ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?",
lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።",
originalScore: "የፈታኙ ጠቅላላ",
derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",
@@ -778,6 +792,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

@@ -253,6 +253,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',
@@ -260,6 +261,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',
@@ -323,6 +329,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',
@@ -368,6 +378,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: {
@@ -687,6 +699,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',
@@ -780,6 +794,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: {

View File

@@ -0,0 +1,50 @@
import { Button, Card, Stack, Text, ThemeIcon, Title } from '@mantine/core';
import { IconCircleCheck, IconClockPause } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import type { AttemptStatus } from '../types/exam-attempt';
/**
* No score, no pass/fail, nothing evaluation-shaped — grading hasn't run.
* This only confirms what actually happened: the candidate submitted, or
* the deadline closed the attempt out first.
*/
export function ExamCompletion({
status,
submittedAt,
}: {
status: AttemptStatus;
submittedAt: string | null;
}) {
const navigate = useNavigate();
const expired = status === 'EXPIRED';
return (
<Stack maw={520} mx="auto" align="center" py="xl">
<Card withBorder radius="lg" p="xl" w="100%">
<Stack align="center" gap="md">
<ThemeIcon size={64} radius="xl" variant="light" color={expired ? 'orange' : 'teal'}>
{expired ? <IconClockPause size={32} /> : <IconCircleCheck size={32} />}
</ThemeIcon>
<Title order={3} ta="center">
{expired ? 'Time expired' : 'Exam submitted'}
</Title>
<Text ta="center" c="dimmed">
{expired
? 'The scheduled time ran out. Your saved answers were recorded as your final submission.'
: 'Your answers have been recorded.'}
{' '}Your result will appear on the Examinations page once marking, moderation and
approval are complete it is not available yet.
</Text>
{submittedAt && (
<Text fz="xs" c="dimmed">
{expired ? 'Closed' : 'Submitted'} at {new Date(submittedAt).toLocaleString()}
</Text>
)}
<Button variant="light" onClick={() => navigate('/exams')}>
Back to Examinations
</Button>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,94 @@
import { Alert, Badge, Button, Card, Group, Stack, Text, Title } from '@mantine/core';
import { IconAlertCircle, IconClock, IconInfoCircle, IconPlayerPlay } from '@tabler/icons-react';
import type { Bilingual } from '@ema-platform/api';
import type { EstimatedTime, RegistrationWithExam } from '../types/exam-attempt';
function formatDuration(time: EstimatedTime | null | undefined): string {
if (!time) return 'Not configured';
const parts = [
time.days ? `${time.days}d` : null,
time.hours ? `${time.hours}h` : null,
time.minutes ? `${time.minutes}m` : null,
].filter(Boolean);
return parts.length ? parts.join(' ') : '0m';
}
export function ExamInstructions({
registration,
localized,
showDate,
starting,
onStart,
}: {
registration: RegistrationWithExam;
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
starting: boolean;
onStart: () => void;
}) {
const exam = registration.exam;
const canStart = exam?.status === 'ACTIVE';
return (
<Stack maw={720} mx="auto" gap="md">
<Title order={2}>{localized(exam?.title) || 'Examination'}</Title>
<Text c="dimmed">{localized(exam?.certification?.name)}</Text>
<Card withBorder radius="md" p="lg">
<Stack gap="sm">
<Group justify="space-between">
<Text fz="sm" c="dimmed">Admission number</Text>
<Text fz="sm" fw={600} ff="monospace">{registration.admissionNumber}</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Session date</Text>
<Text fz="sm">{showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''}</Text>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Duration</Text>
<Badge variant="light" leftSection={<IconClock size={12} />}>
{formatDuration(exam?.givenTime)}
</Badge>
</Group>
<Group justify="space-between">
<Text fz="sm" c="dimmed">Attempt</Text>
<Badge variant="light" color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}>
{registration.kind === 'RETAKE'
? `Retake · sitting ${registration.attemptNumber}`
: 'First sitting'}
</Badge>
</Group>
</Stack>
</Card>
{exam?.direction && localized(exam.direction) && (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light" title="Instructions">
{localized(exam.direction)}
</Alert>
)}
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light">
Once started, the timer cannot be paused. Answers are saved automatically as you go, but
the exam ends the moment the deadline passes, whether or not you have submitted.
</Alert>
{!canStart && (
<Alert color="gray" variant="light">
This session is not currently open for candidates to begin.
</Alert>
)}
<Group justify="flex-end">
<Button
size="md"
leftSection={<IconPlayerPlay size={16} />}
loading={starting}
disabled={!canStart}
onClick={onStart}
>
Start exam
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,118 @@
import { Badge, Button, Group, Paper, Radio, Stack, Text, Textarea } from '@mantine/core';
import { IconAlertCircle, IconCheck, IconRefresh } from '@tabler/icons-react';
import type { Bilingual } from '@ema-platform/api';
import type { CandidateQuestion, SaveState } from '../types/exam-attempt';
function SaveIndicator({ state, onRetry }: { state: SaveState; onRetry: () => void }) {
if (state === 'saving') {
return <Text fz="xs" c="dimmed">Saving</Text>;
}
if (state === 'saved') {
return (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal">Saved</Text>
</Group>
);
}
if (state === 'error') {
return (
<Group gap={6}>
<IconAlertCircle size={13} color="var(--mantine-color-red-6)" />
<Text fz="xs" c="red">Not saved</Text>
<Button
size="compact-xs"
variant="light"
color="red"
leftSection={<IconRefresh size={12} />}
onClick={onRetry}
>
Retry
</Button>
</Group>
);
}
return null;
}
/**
* Renders one question — never the answer key, because the API response
* this reads from (`CandidateQuestion`/`CandidateOption`) has no such field
* to render even by mistake.
*/
export function ExamQuestionDisplay({
question,
index,
total,
localized,
selectedOptionId,
answerText,
saveState,
disabled,
onSelectOption,
onChangeText,
onRetry,
}: {
question: CandidateQuestion;
index: number;
total: number;
localized: (value: Bilingual | undefined) => string;
selectedOptionId: string | null | undefined;
answerText: string | null | undefined;
saveState: SaveState;
disabled: boolean;
onSelectOption: (optionId: string) => void;
onChangeText: (text: string) => void;
onRetry: () => void;
}) {
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" mb="sm">
<Badge variant="light" color="gray">
Question {index + 1} of {total} · {question.points} pts
</Badge>
<SaveIndicator state={saveState} onRetry={onRetry} />
</Group>
<Text fz="md" fw={500} mb="lg">
{localized(question.title)}
</Text>
{question.form === 'CHOICE' ? (
<Radio.Group
value={selectedOptionId ?? ''}
onChange={onSelectOption}
>
<Stack gap="sm">
{question.options
.slice()
.sort((a, b) => a.order - b.order)
.map((option) => (
<Radio.Card
key={option.id}
value={option.id}
disabled={disabled}
p="sm"
radius="md"
>
<Group wrap="nowrap" gap="sm">
<Radio.Indicator disabled={disabled} />
<Text fz="sm">{localized(option.text)}</Text>
</Group>
</Radio.Card>
))}
</Stack>
</Radio.Group>
) : (
<Textarea
placeholder="Write your answer"
minRows={8}
autosize
disabled={disabled}
value={answerText ?? ''}
onChange={(event) => onChangeText(event.currentTarget.value)}
/>
)}
</Paper>
);
}

View File

@@ -0,0 +1,57 @@
import { Paper, SimpleGrid, Text, UnstyledButton } from '@mantine/core';
import type { CandidateQuestion } from '../types/exam-attempt';
export function ExamQuestionNav({
questions,
currentIndex,
answeredIds,
disabled,
onJump,
}: {
questions: CandidateQuestion[];
currentIndex: number;
answeredIds: Set<string>;
disabled: boolean;
onJump: (index: number) => void;
}) {
return (
<Paper withBorder radius="md" p="sm">
<Text fz="xs" fw={600} c="dimmed" mb="xs" tt="uppercase">
Questions
</Text>
<SimpleGrid cols={5} spacing={6}>
{questions.map((q, index) => {
const answered = answeredIds.has(q.id);
const current = index === currentIndex;
return (
<UnstyledButton
key={q.id}
disabled={disabled}
onClick={() => onJump(index)}
style={{
height: 34,
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
fontSize: 13,
border: current ? '2px solid var(--mantine-color-blue-6)' : '1px solid var(--mantine-color-gray-4)',
background: answered
? 'var(--mantine-color-teal-1)'
: 'var(--mantine-color-body)',
color: answered ? 'var(--mantine-color-teal-8)' : undefined,
opacity: disabled ? 0.5 : 1,
}}
>
{index + 1}
</UnstyledButton>
);
})}
</SimpleGrid>
<Text fz="xs" c="dimmed" mt="sm">
{answeredIds.size} of {questions.length} answered
</Text>
</Paper>
);
}

View File

@@ -0,0 +1,34 @@
import { Badge, Group } from '@mantine/core';
import { IconClock } from '@tabler/icons-react';
function format(totalSeconds: number): string {
const s = Math.max(0, totalSeconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
}
/**
* Display only. `remainingSeconds` is a local countdown seeded once from the
* server's own clock (`AttemptSession.remainingSeconds`/`serverTime`) and
* ticked down client-side — the deadline it represents is enforced by the
* backend on every save/submit regardless of whether this number is right.
*/
export function ExamTimer({ remainingSeconds }: { remainingSeconds: number }) {
const low = remainingSeconds <= 300; // 5 minutes
return (
<Group gap={6}>
<Badge
size="lg"
variant="light"
color={low ? 'red' : 'blue'}
leftSection={<IconClock size={14} />}
ff="monospace"
>
{format(remainingSeconds)}
</Badge>
</Group>
);
}

View File

@@ -0,0 +1,286 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useApiMutation, useApiQuery, extractErrorMessage } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import type {
AttemptSession,
CandidateAnswer,
ExamAttempt,
RegistrationWithExam,
SaveState,
} from '../types/exam-attempt';
const ESSAY_DEBOUNCE_MS = 1500;
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
type LocalAnswer = { selectedOptionId?: string | null; answerText?: string | null };
/**
* All state and API orchestration for taking one exam. Kept out of the page
* component so the component tree stays about rendering, not about save
* timers and expiry races.
*
* Nothing here is a security boundary — every write still goes through the
* backend's own ownership + `applyExpiry()` checks on every call. This hook
* only decides what to show; a client that skipped straight to calling the
* API directly would hit exactly the same server-side rules.
*/
export function useExamAttempt(examId: string | undefined) {
const [session, setSession] = useState<AttemptSession | null>(null);
const [viewState, setViewState] = useState<ViewState>('loading');
const [errorMessage, setErrorMessage] = useState('');
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, LocalAnswer>>({});
const [saveStates, setSaveStates] = useState<Record<string, SaveState>>({});
const [remainingSeconds, setRemainingSeconds] = useState(0);
const answersRef = useRef(answers);
answersRef.current = answers;
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const seeded = useRef(false);
const {
data: registrations,
isLoading: loadingRegistrations,
} = useApiQuery<RegistrationWithExam[]>({ url: '/exams/registrations/mine' });
const registration = registrations?.find((r) => r.exam?.id === examId);
const {
data: mineData,
isLoading: loadingMine,
isError: mineIsError,
error: mineError,
refetch: refetchMine,
} = useApiQuery<AttemptSession>(
{ url: `/exam-attempts/mine/${examId}` },
{ skip: !examId },
);
const [startTrigger, { isLoading: starting }] = useApiMutation<AttemptSession>();
const [answerTrigger] = useApiMutation<CandidateAnswer>();
const [submitTrigger, { isLoading: submitting }] = useApiMutation<ExamAttempt>();
const seedFrom = useCallback((data: AttemptSession) => {
setSession(data);
const map: Record<string, LocalAnswer> = {};
for (const a of data.answers) {
map[a.questionId] = { selectedOptionId: a.selectedOptionId, answerText: a.answerText };
}
setAnswers(map);
setRemainingSeconds(data.remainingSeconds);
setViewState(data.attempt.status === 'IN_PROGRESS' ? 'taking' : 'completed');
}, []);
// Seed once from the initial load — after that, local state (ticking
// timer, in-flight edits) is the source of truth, not this query.
useEffect(() => {
if (seeded.current) return;
if (loadingRegistrations || loadingMine) return;
seeded.current = true;
if (!registration) {
setViewState('error');
setErrorMessage('You are not registered for this examination.');
return;
}
if (mineData) {
seedFrom(mineData);
return;
}
if (mineIsError) {
const key = extractErrorMessage(mineError, '');
if (key === 'attempt_not_found') {
setViewState('not-started');
return;
}
setViewState('error');
setErrorMessage(extractErrorMessage(mineError, 'Could not load the exam.'));
}
}, [loadingRegistrations, loadingMine, registration, mineData, mineIsError, mineError, seedFrom]);
/** Authoritative resync — used after any write is refused as expired/submitted. */
const syncFromServer = useCallback(async () => {
const result = await refetchMine();
if (result.data) {
seedFrom(result.data as AttemptSession);
} else {
setViewState('error');
setErrorMessage(extractErrorMessage(result.error, 'The exam session ended.'));
}
}, [refetchMine, seedFrom]);
const persistAnswer = useCallback(
async (questionId: string, payload: LocalAnswer) => {
if (!session) return;
setSaveStates((s) => ({ ...s, [questionId]: 'saving' }));
try {
const saved = await answerTrigger({
url: `/exam-attempts/${session.attempt.id}/answers`,
method: 'POST',
body: { questionId, ...payload },
}).unwrap();
setAnswers((a) => ({
...a,
[questionId]: { selectedOptionId: saved.selectedOptionId, answerText: saved.answerText },
}));
setSaveStates((s) => ({ ...s, [questionId]: 'saved' }));
} catch (error) {
setSaveStates((s) => ({ ...s, [questionId]: 'error' }));
const key = extractErrorMessage(error, '');
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
notify.error(
key === 'attempt_expired'
? 'Time is up — this answer was not saved.'
: 'This attempt has already been submitted.',
);
syncFromServer();
}
}
},
[session, answerTrigger, syncFromServer],
);
const flush = useCallback(
(questionId: string) => {
const timer = debounceTimers.current[questionId];
if (!timer) return;
clearTimeout(timer);
delete debounceTimers.current[questionId];
const current = answersRef.current[questionId];
if (current) persistAnswer(questionId, current);
},
[persistAnswer],
);
const selectOption = useCallback(
(questionId: string, optionId: string) => {
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], selectedOptionId: optionId } }));
persistAnswer(questionId, { selectedOptionId: optionId });
},
[persistAnswer],
);
const changeText = useCallback(
(questionId: string, text: string) => {
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], answerText: text } }));
setSaveStates((s) => ({ ...s, [questionId]: 'idle' }));
clearTimeout(debounceTimers.current[questionId]);
debounceTimers.current[questionId] = setTimeout(() => {
delete debounceTimers.current[questionId];
persistAnswer(questionId, { answerText: text });
}, ESSAY_DEBOUNCE_MS);
},
[persistAnswer],
);
const goTo = useCallback(
(index: number) => {
const current = session?.questions[currentIndex];
if (current) flush(current.id);
setCurrentIndex(index);
},
[session, currentIndex, flush],
);
const retry = useCallback(
(questionId: string) => {
const current = answersRef.current[questionId];
if (current) persistAnswer(questionId, current);
},
[persistAnswer],
);
const start = useCallback(async () => {
if (!examId) return;
try {
const result = await startTrigger({
url: '/exam-attempts/start',
method: 'POST',
body: { examId },
}).unwrap();
seedFrom(result);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
}
}, [examId, startTrigger, seedFrom]);
const submit = useCallback(async () => {
if (!session) return;
const current = session.questions[currentIndex];
if (current) flush(current.id);
try {
const attempt = await submitTrigger({
url: `/exam-attempts/${session.attempt.id}/submit`,
method: 'POST',
}).unwrap();
setSession((s) => (s ? { ...s, attempt } : s));
setViewState('completed');
} catch (error) {
const key = extractErrorMessage(error, '');
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
syncFromServer();
} else {
notify.error(extractErrorMessage(error, 'Could not submit the exam.'));
}
}
}, [session, currentIndex, flush, submitTrigger, syncFromServer]);
/** Local countdown only — every write is still checked server-side regardless. */
useEffect(() => {
if (viewState !== 'taking') return;
const id = setInterval(() => {
setRemainingSeconds((s) => {
if (s <= 1) {
clearInterval(id);
return 0;
}
return s - 1;
});
}, 1000);
return () => clearInterval(id);
}, [viewState]);
// Time reaching zero locally: stop taking input, tell the server, then
// trust whatever it reports back over anything computed in the browser.
const timedOutRef = useRef(false);
useEffect(() => {
if (viewState !== 'taking' || remainingSeconds > 0 || timedOutRef.current) return;
timedOutRef.current = true;
notify.error("Time's up.");
// submit() itself resyncs from the server if this loses the race against
// applyExpiry() — either way the final state comes from the backend, not
// from this timer having reached zero.
submit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remainingSeconds, viewState]);
const answeredIds = useMemo(
() =>
new Set(
Object.entries(answers)
.filter(([, v]) => v.selectedOptionId || v.answerText?.trim())
.map(([id]) => id),
),
[answers],
);
return {
viewState,
errorMessage,
registration,
session,
currentIndex,
answers,
saveStates,
answeredIds,
remainingSeconds,
starting,
submitting,
start,
selectOption,
changeText,
goTo,
retry,
submit,
};
}

View File

@@ -0,0 +1,187 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Alert, Button, Center, Group, Loader, Modal, Stack, Text } from '@mantine/core';
import { IconAlertCircle, IconSend } from '@tabler/icons-react';
import { useLocalized } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
import { useExamAttempt } from '../../hooks/useExamAttempt';
import { ExamInstructions } from '../../components/ExamInstructions';
import { ExamTimer } from '../../components/ExamTimer';
import { ExamQuestionNav } from '../../components/ExamQuestionNav';
import { ExamQuestionDisplay } from '../../components/ExamQuestionDisplay';
import { ExamCompletion } from '../../components/ExamCompletion';
/**
* The candidate exam-taking screen (Phase 4). Route: `/exams/:examId/take`.
*
* All state/API orchestration lives in `useExamAttempt` — this component is
* the view: pick which of loading/not-started/taking/completed/error to
* render. Every write it triggers (start, save, submit) is re-checked by the
* backend regardless of what this screen currently shows; nothing here is
* the actual security boundary.
*/
export function ExamAttemptPage() {
const { examId } = useParams<{ examId: string }>();
const localized = useLocalized();
const showDate = useDateDisplayer();
const [confirmOpened, setConfirmOpened] = useState(false);
const {
viewState,
errorMessage,
registration,
session,
currentIndex,
answers,
saveStates,
answeredIds,
remainingSeconds,
starting,
submitting,
start,
selectOption,
changeText,
goTo,
retry,
submit,
} = useExamAttempt(examId);
if (viewState === 'loading') {
return (
<Center py="xl">
<Loader />
</Center>
);
}
if (viewState === 'error') {
return (
<Alert icon={<IconAlertCircle size={16} />} color="red" maw={600} mx="auto" mt="xl">
{errorMessage}
</Alert>
);
}
if (viewState === 'not-started') {
if (!registration) return null; // guarded by 'error' above, appeases TS
return (
<ExamInstructions
registration={registration}
localized={localized}
showDate={showDate}
starting={starting}
onStart={start}
/>
);
}
if (viewState === 'completed' && session) {
return (
<ExamCompletion status={session.attempt.status} submittedAt={session.attempt.submittedAt} />
);
}
if (!session) return null; // 'taking' always has a session by construction
const question = session.questions[currentIndex];
const answer = answers[question.id];
return (
<Stack maw={1000} mx="auto" gap="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>{localized(registration?.exam?.title) || 'Examination in progress'}</Text>
<ExamTimer remainingSeconds={remainingSeconds} />
</Group>
<Group align="flex-start" gap="md" wrap="wrap-reverse">
<div style={{ flex: 1, minWidth: 280 }}>
<ExamQuestionDisplay
question={question}
index={currentIndex}
total={session.questions.length}
localized={localized}
selectedOptionId={answer?.selectedOptionId}
answerText={answer?.answerText}
saveState={saveStates[question.id] ?? 'idle'}
disabled={remainingSeconds <= 0}
onSelectOption={(optionId) => selectOption(question.id, optionId)}
onChangeText={(text) => changeText(question.id, text)}
onRetry={() => retry(question.id)}
/>
<Group justify="space-between" mt="md">
<Button
variant="default"
disabled={currentIndex === 0}
onClick={() => goTo(currentIndex - 1)}
>
Previous
</Button>
{currentIndex < session.questions.length - 1 ? (
<Button onClick={() => goTo(currentIndex + 1)}>Next</Button>
) : (
<Button
color="teal"
leftSection={<IconSend size={15} />}
onClick={() => setConfirmOpened(true)}
>
Submit exam
</Button>
)}
</Group>
</div>
<div style={{ width: 220, flexShrink: 0 }}>
<ExamQuestionNav
questions={session.questions}
currentIndex={currentIndex}
answeredIds={answeredIds}
disabled={remainingSeconds <= 0}
onJump={goTo}
/>
<Button
fullWidth
mt="sm"
variant="light"
color="teal"
leftSection={<IconSend size={15} />}
onClick={() => setConfirmOpened(true)}
>
Submit exam
</Button>
</div>
</Group>
<Modal
opened={confirmOpened}
onClose={() => setConfirmOpened(false)}
title="Submit this exam?"
radius="lg"
>
<Stack>
<Text size="sm">
{answeredIds.size} of {session.questions.length} questions answered. Once submitted,
answers cannot be changed.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setConfirmOpened(false)}>
Keep working
</Button>
<Button
color="teal"
loading={submitting}
onClick={async () => {
await submit();
setConfirmOpened(false);
}}
>
Submit
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}
export default ExamAttemptPage;

View File

@@ -0,0 +1,78 @@
import type { Bilingual } from '@ema-platform/api';
export type AttemptStatus = 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
export type QuestionForm = 'ESSAY' | 'CHOICE';
export interface CandidateOption {
id: string;
text: Bilingual;
order: number;
}
/** Never carries a correct-answer flag — the API doesn't send one. */
export interface CandidateQuestion {
id: string;
title: Bilingual;
form: QuestionForm;
points: number;
options: CandidateOption[];
}
export interface ExamAttempt {
id: string;
examId: string;
registrationId: string;
status: AttemptStatus;
startedAt: string;
expiresAt: string;
submittedAt: string | null;
}
export interface CandidateAnswer {
id: string;
attemptId: string;
questionId: string;
selectedOptionId: string | null;
answerText: string | null;
}
/** Response shape shared by POST /exam-attempts/start and GET .../mine/:examId. */
export interface AttemptSession {
attempt: ExamAttempt;
questions: CandidateQuestion[];
answers: CandidateAnswer[];
serverTime: string;
remainingSeconds: number;
}
export type SaveState = 'idle' | 'saving' | 'saved' | 'error';
export interface EstimatedTime {
days: number;
hours: number;
minutes: number;
}
/**
* The subset of `GET /exams/registrations/mine`'s response this feature
* reads — the endpoint returns the full raw exam/registration, this is
* just this feature's own narrow view of it (matches the sibling `exams`
* feature's pattern of each screen typing only what it uses).
*/
export interface RegistrationWithExam {
id: string;
admissionNumber: string;
kind: 'NEW' | 'RETAKE';
attemptNumber: number;
attendanceStatus: string;
exam?: {
id: string;
title: Bilingual;
direction?: Bilingual;
date: string;
venue: string | null;
status: string;
givenTime: EstimatedTime | null;
certification?: { name?: Bilingual };
};
}

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconFileText, IconGavel } from '@tabler/icons-react';
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual } from '@ema-platform/api';
@@ -20,6 +20,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
DISQUALIFIED: 'red',
};
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
export function registrationColumns(
t: TFunction,
deps: {
@@ -28,6 +30,7 @@ export function registrationColumns(
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
onDownloadSlip: (registration: MyRegistration) => void;
onStartExam: (registration: MyRegistration) => void;
},
): AdvancedColumn<MyRegistration>[] {
return [
@@ -91,6 +94,44 @@ export function registrationColumns(
</Button>
) : null,
},
{
header: t('exams.columns.exam'),
cell: ({ row }) => {
const exam = row.original.exam;
const attemptStatus = row.original.attempt?.status;
// Already finished — no restart, no more room for "Take exam" to
// invite a click that the backend would just refuse.
if (attemptStatus === 'SUBMITTED') {
return (
<Badge size="sm" variant="light" color="teal">
{t('exams.columns.completed')}
</Badge>
);
}
if (attemptStatus === 'EXPIRED') {
return (
<Badge size="sm" variant="light" color="red">
{t('exams.columns.timeExpired')}
</Badge>
);
}
const eligible =
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
return (
<Button
size="compact-xs"
color="teal"
leftSection={<IconPlayerPlay size={13} />}
onClick={() => deps.onStartExam(row.original)}
>
{attemptStatus === 'IN_PROGRESS'
? t('exams.columns.resumeExam')
: t('exams.columns.takeExam')}
</Button>
);
},
},
];
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Badge,
Button,
@@ -53,6 +54,8 @@ export interface MyRegistration {
attemptNumber: number;
attendanceStatus: AttendanceStatus;
exam?: OpenExam;
/** The candidate's online sitting, when one has been started. */
attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
}
export interface MyResult {
@@ -80,6 +83,7 @@ export interface MyAppeal {
*/
export function ExamsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const showDate = useDateDisplayer();
const localized = useLocalized();
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
@@ -245,6 +249,7 @@ export function ExamsPage() {
localized,
showDate,
onDownloadSlip: downloadSlip,
onStartExam: (registration) => navigate(`/exams/${registration.exam?.id}/take`),
})}
data={pagedRegistrations.rows}
itemCount={pagedRegistrations.itemCount}

View File

@@ -969,6 +969,11 @@ export const am: Translations = {
appeal: 'ይግባኝ',
retake: 'ድጋሚ · {{n}}',
firstSitting: 'የመጀመሪያ ሙከራ',
exam: 'ፈተና',
completed: 'ተጠናቋል',
timeExpired: 'ጊዜው አልቋል',
resumeExam: 'ፈተና ይቀጥሉ',
takeExam: 'ፈተና ይውሰዱ',
attendanceStatus: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',

View File

@@ -970,6 +970,11 @@ export const en = {
appeal: 'Appeal',
retake: 'Retake · {{n}}',
firstSitting: 'First sitting',
exam: 'Exam',
completed: 'Completed',
timeExpired: 'Time expired',
resumeExam: 'Resume exam',
takeExam: 'Take exam',
attendanceStatus: {
REGISTERED: 'Not called',
PRESENT: 'Present',

View File

@@ -29,6 +29,7 @@ import { SupportPage } from "./features/support/pages/SupportPage";
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
import { ExamsPage } from "./features/exams/pages/ExamsPage";
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
// Phase 1 pages
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
@@ -208,6 +209,14 @@ export const router = createBrowserRouter([
</RequirePermission>
),
},
{
path: "/exams/:examId/take",
element: (
<RequirePermission anyOf={[P.APPLY_EXAM, P.VIEW_OWN_EXAM]}>
<ExamAttemptPage />
</RequirePermission>
),
},
// The public-facing registry was a hardcoded mock and does not belong in
// the applicant portal; officers browse seafarers in the backoffice.
{

468
ema-exam-gap-analysis.md Normal file
View File

@@ -0,0 +1,468 @@
# EMA Exam System — Combined Gap Analysis
**Repos:** `/home/tria/projects/mihretu/emaui` (frontend, branch `dev`) · `/home/tria/projects/mihretu/emaapi` (backend, branch `dev`). Read-only — no files modified, nothing implemented, nothing committed, no branch created.
**The user story, as directed:** this task's own exam-lifecycle checklist (§5 of the brief — Exam Administration, Question Management, Candidate Management, Candidate Exam-Taking, Evaluation, Post-Exam) is treated as the requirements source, flattened below into 54 individually numbered, non-combined requirements. Every status below is traced to actual file:line evidence gathered from a full read of the relevant frontend and backend source — not inferred from documentation, comments, or the presence of an endpoint alone.
---
## 1. The User Story — Numbered Requirements
### Exam Administration
1. Create exam
2. Configure exam (type/form/administration/evaluation/pass-mark/selection method as one settings unit)
3. Define exam type
4. Define subjects
5. Define sections
6. Configure duration
7. Configure passing score
8. Configure attempts
9. Configure exam status
10. Configure availability
### Question Management
11. Create question
12. Edit question
13. Delete question
14. Question bank
15. Question types
16. MCQ options
17. Correct answers
18. Question scoring
19. Question ordering
20. Random questions
21. Paper assembly
### Candidate Management
22. Candidate registration
23. Candidate eligibility
24. Candidate assignment
25. Attendance
26. Candidate status
### Candidate Exam-Taking
27. Exam instructions
28. Start exam
29. Create attempt/session
30. Timer
31. Question navigation
32. Display questions
33. Select answers
34. Save answers
35. Auto-save
36. Resume attempt
37. Submit exam
38. Prevent submission after expiration
39. Prevent unauthorized access
### Evaluation
40. Automatic grading
41. Manual grading
42. Score calculation
43. Pass/fail
44. Result generation
45. Result review
46. Result approval
47. Result publication
### Post-Exam
48. Result viewing
49. Appeals
50. Appeal review
51. Retakes
52. Exam history
53. Notifications
54. Certificates, if required
---
## 2. Frontend Comparison
| # | Requirement | Status | Evidence |
|---|---|---|---|
| 1 | Create exam | ✅ | `apps/backoffice/.../exam/pages/ExamPage.tsx` — full create form → `useCreateExamMutation` |
| 2 | Configure exam | ✅ | Same form, Settings tab: type/form/administration/evaluation/selection method/cutting point/status |
| 3 | Define exam type | ✅ | `ExamPage.tsx` — WRITTEN/ORAL `Select`, required |
| 4 | Define subjects | ✅ | `ExamPage.tsx` — certification `Select`; full CRUD at `certification/pages/CertificationPage.tsx` |
| 5 | Define sections | 🔴 | No component, field, or route anywhere in the exam feature tree |
| 6 | Configure duration | ✅ | `ExamPage.tsx` — days/hours/minutes inputs, captured on create/edit |
| 7 | Configure passing score | ✅ | `ExamPage.tsx` — cutting-point `NumberInput`, required |
| 8 | Configure attempts | 🟡 | No admin-facing control anywhere — attempt/retake is read-only display in the portal (`ExamsPage.tsx` badge), never a setting an officer configures |
| 9 | Configure exam status | ✅ | `ExamPage.tsx` — status `Select`, shown only when editing |
| 10 | Configure availability | 🟡 | No explicit "availability" control found; portal's `GET /exams/open` list is consumed as-is with no frontend criteria of its own |
| 11 | Create question | ✅ | `question/pages/QuestionPage.tsx` form → `useCreateQuestionMutation` |
| 12 | Edit question | ✅ | Same form → `useUpdateQuestionMutation` |
| 13 | Delete question | ✅ | Delete button + confirm modal → `useDeleteQuestionMutation` |
| 14 | Question bank | ✅ | `QuestionPage.tsx` list, filterable by certification |
| 15 | Question types | 🟡 | Only two labels selectable: ESSAY / CHOICE (`QuestionForm` type in `question/types/question.ts`) |
| 16 | MCQ options | 🔴 | No `options`/`choices` field anywhere in `question.ts`, the create/edit form, or any component |
| 17 | Correct answers | 🔴 | No `correctAnswer`/answer-key field or control anywhere |
| 18 | Question scoring | ✅ | `points` field on the question form; bounded score entry in `RecordResultModal.tsx` |
| 19 | Question ordering | ⚠️ | No ordering UI found; not confirmed whether the assignment list has any inherent order |
| 20 | Random questions | ✅ | `exam/components/QuestionAssigner.tsx` (random mode) → `useSelectRandomQuestionsMutation` |
| 21 | Paper assembly | ✅ | `QuestionAssigner.tsx` (manual mode) → `useAssignQuestionsMutation` |
| 22 | Candidate registration | ✅ | Portal `exams/pages/ExamsPage.tsx` → register button → mutation to `/exams/:id/register` |
| 23 | Candidate eligibility | ✅ | Same page — maps `seafarer_registration_required`/`subject_already_passed` server errors to readable copy before/after attempting registration |
| 24 | Candidate assignment | 🔴 | `exam/components/ExamCandidatesPanel.tsx` can only view + mark attendance; no add/remove-candidate control anywhere in the backoffice |
| 25 | Attendance | ✅ | `ExamCandidatesPanel.tsx` — per-candidate + bulk marking, required remark on Withdrawn/Disqualified |
| 26 | Candidate status | ✅ | Same panel — attendance-status badges (Registered/Present/Absent/Late/Withdrawn/Disqualified) |
| 27 | Exam instructions | 🟡 | Bilingual `direction` field captured on the exam form; no screen anywhere displays it to a candidate |
| 28 | Start exam | 🔴 | No route, component, button, or handler anywhere in `apps/portal` |
| 29 | Create attempt/session | 🔴 | No concept anywhere — no attempt/session state, type, or API call |
| 30 | Timer | 🔴 | No countdown/timer component anywhere in either app |
| 31 | Question navigation | 🔴 | No question-by-question view exists to navigate |
| 32 | Display questions | 🔴 | No candidate-facing question-rendering component anywhere |
| 33 | Select answers | 🔴 | No answer-selection UI/state anywhere |
| 34 | Save answers | 🔴 | No save action or API call anywhere |
| 35 | Auto-save | 🔴 | No auto-save timer/effect anywhere in the exam feature (the licensing wizard's per-section `PATCH` auto-save pattern exists but is not used by, or wired to, anything exam-related) |
| 36 | Resume attempt | 🔴 | No resume logic anywhere (nothing to resume) |
| 37 | Submit exam | 🔴 | No submit action anywhere distinct from application/appeal "submit" flows |
| 38 | Prevent submission after expiration | 🔴 | No expiration check anywhere (no timer to expire) |
| 39 | Prevent unauthorized access | 🔵 | Not applicable — no attempt exists for an access check to protect |
| 40 | Automatic grading | 🔴 | No grading logic in the frontend at all (grading is entirely a backend concern either way) |
| 41 | Manual grading | ✅ | `result/components/RecordResultModal.tsx` — bounded per-question score entry, live pass/fail preview |
| 42 | Score calculation | ✅ | Same component — client preview mirrors the exam's evaluation method (SUM/AVERAGE/PERCENTAGE) |
| 43 | Pass/fail | ✅ | Same — preview only; frontend never sends a `status`/outcome field, matching backend design |
| 44 | Result generation | ✅ | `RecordResultModal.tsx``useCreateResultMutation` |
| 45 | Result review | ✅ | `result/pages/ResultPage.tsx` — moderate/return actions with required remarks |
| 46 | Result approval | ✅ | Same page — approve action |
| 47 | Result publication | ✅ | Same page — publish-per-exam action, reports published/skipped counts |
| 48 | Result viewing | ✅ | Portal `ExamsPage.tsx` — "My results" table |
| 49 | Appeals | ✅ | Portal `ExamsPage.tsx` — appeal modal with required reason, 14-day-window copy |
| 50 | Appeal review | ✅ | Backoffice `result/pages/ExamAppealsPage.tsx` — uphold/reject with required remark |
| 51 | Retakes | 🟡 | Display-only: portal shows `kind`/`attemptNumber` badges; no dedicated retake workflow beyond ordinary registration |
| 52 | Exam history | 🟡 | No dedicated history view; the registrations table and results table (both already counted above) are the closest equivalent |
| 53 | Notifications | ⚠️ | Portal `notifications/pages/NotificationsPage.tsx` is generic (`GET /notifications`); not confirmed whether/how exam-specific notices render distinctly |
| 54 | Certificates, if required | 🔴 | `certificate-designer/pages/CertificateDesignerPage.tsx` exists but targets license/permit templates (`licensing-api.ts`'s `license-templates` endpoints) — confirmed via its own data hooks — not exam results |
---
## 3. Backend Comparison
*(Global note, applies to every "NO GUARD" row below: `JwtGuard` is registered as a global `APP_GUARD` — every route requires a valid authenticated session regardless. "NO GUARD" means no `PermissionGuard` role/permission check on top of that base authentication — i.e., reachable by any signed-in account, not by the public.)*
| # | Requirement | Status | Evidence |
|---|---|---|---|
| 1 | Create exam | 🟡 | `POST /exams` exists (`exam.controller.ts`, inherited `CommonCrudController`) — **no `PermissionGuard`** |
| 2 | Configure exam | 🟡 | `PUT /exams/:id` — same route, same gap; all settings fields are real columns on `exams` (`exam.entity.ts`) |
| 3 | Define exam type | ✅ | `EExamType` enum (`common/enums/enums.ts`), `Exam.type` column |
| 4 | Define subjects | 🟡 | `certificationId` FK is solid; but `certification.controller.ts` (bare `CommonCrudController`) has **no guard at all** on its full CRUD, including the write that would toggle `isActive` |
| 5 | Define sections | 🔴 | No table, column, or entity anywhere in the exam schema (confirmed against base migration `1785742151690-EmaBaseline.ts` + both later exam migrations) |
| 6 | Configure duration | 🟡 | `Exam.givenTime`/`Question.time` (jsonb) store it, but nothing in `exam.service.ts`/`result.service.ts` ever reads it against a clock |
| 7 | Configure passing score | ✅ | `Exam.cuttingPoint` (numeric), consumed by `result.service.ts::outcomeFor()` |
| 8 | Configure attempts | ✅ (as built) | `ExamRegistrationService.resolveAttempt()` fully, automatically derives attempt number/kind server-side — no client input accepted, no admin-configurable cap exists (a candidate may retake indefinitely unless their most recent published result is PASSED) |
| 9 | Configure exam status | ✅ | `EExamStatus` enum (PENDING/ACTIVE/COMPLETED/CANCELLED/POSTPONED/PUBLISHED), `Exam.status` column |
| 10 | Configure availability | ⚠️ | `GET /exams/open` route exists (`exam.controller.ts`); exact server-side "open" criteria not independently traced in this pass |
| 11 | Create question | 🟡 | `POST /questions` (inherited `CommonCrudController`) — **no `PermissionGuard`** |
| 12 | Edit question | 🟡 | `PUT /questions/:id` — same gap |
| 13 | Delete question | 🟡 | `DELETE /questions/:id` — same gap |
| 14 | Question bank | 🟡 | `GET /questions` — same gap; returns rows of every `EQuestionStatus` (DRAFT/PENDING_APPROVAL/APPROVED/REJECTED/RETIRED) to any authenticated caller, no status filter applied server-side for unprivileged callers |
| 15 | Question types | 🟡 | `EExamForm` enum: `ESSAY \| CHOICE` only — a category label, nothing deeper |
| 16 | MCQ options | 🔴 | `question.entity.ts` (confirmed full column list) and `question.dto.ts` (both create/update payloads) carry no options/choices field of any kind |
| 17 | Correct answers | 🔴 | No `correctAnswer`/`answerKey` column anywhere on `Question` or any related table |
| 18 | Question scoring | ✅ | `Question.points` (numeric); `Result.resultBreakdowns[].score` summed in `result.service.ts::grade()` |
| 19 | Question ordering | ⚠️ | `exams_questions` is a plain composite-PK join table (`exam_id`, `question_id`) — no order/sequence column found |
| 20 | Random questions | 🟡 | `POST /exams/:id/questions/random` — real server-side draw, correctly APPROVED-only filtered, but **no guard** |
| 21 | Paper assembly | 🟡 | `POST /exams/:id/questions` — same gap |
| 22 | Candidate registration | ✅ | `POST /exams/:id/register` — ownership-scoped to the caller, real |
| 23 | Candidate eligibility | ✅ | Same service — ACTIVE-seafarer requirement + `subject_already_passed` hard block enforced server-side |
| 24 | Candidate assignment | 🔴 | No `createRegistration`/admin-add endpoint exists anywhere in `exam-registration.service.ts` — registration is exclusively self-service |
| 25 | Attendance | ✅ | `POST /exams/registrations/:id/attendance` + bulk variant, both guarded `RECORD_EXAM_ATTENDANCE` |
| 26 | Candidate status | ✅ | `EExamAttendanceStatus` enum, matches frontend exactly |
| 27 | Exam instructions | ✅ (storage only) | `Exam.direction` (jsonb) stored and returned; nothing to display it to a candidate exists downstream |
| 28 | Start exam | 🔴 | No route, service method, or entity anywhere |
| 29 | Create attempt/session | 🔴 | Confirmed absent by direct search of every migration and entity — no attempt/session table exists. `common/entities/session.entity.ts` is the **login** session, unrelated |
| 30 | Timer | 🔴 | No timer/expiry logic anywhere in the exam domain |
| 31 | Question navigation | 🔵 | Not applicable server-side (a client concern) — but nothing to serve questions incrementally exists either |
| 32 | Display questions | 🔴 | No candidate-facing "get my exam questions" endpoint exists |
| 33 | Select answers | 🔴 | No answer-capture DTO/entity anywhere |
| 34 | Save answers | 🔴 | No persistence path anywhere |
| 35 | Auto-save | 🔴 | No such endpoint in the exam domain |
| 36 | Resume attempt | 🔴 | No in-progress state exists to resume |
| 37 | Submit exam | 🔴 | No submission event/timestamp on any exam-taking entity (distinct from the unrelated license-application "submit") |
| 38 | Prevent submission after expiration | 🔴 | No expiration check anywhere |
| 39 | Prevent unauthorized access | 🔵 | Not applicable — no attempt entity exists to protect |
| 40 | Automatic grading | 🔴 | `result.service.ts::grade()` sums officer-entered `resultBreakdowns` only; never compares to a stored answer, because none is stored |
| 41 | Manual grading | 🟡 | `POST /results` (create) and `PUT /results/:id` (update) — **no `PermissionGuard`**, despite `RECORD_EXAM_RESULT` existing and being applied to *other* routes in the same controller |
| 42 | Score calculation | ✅ | `grade()` — sum/average/percentage per `Exam.evaluationMethod` |
| 43 | Pass/fail | ✅ | `outcomeFor()` vs `cuttingPoint`; `UpdateResultDto` deliberately has no `status` field — the DTO's own comment notes the old client-declared-PASSED behavior was removed |
| 44 | Result generation | 🟡 | Same route/gap as #41 |
| 45 | Result review | ✅ | `POST /results/:id/moderate` guarded `MODERATE_EXAM_RESULT`, preserves `preModerationScore` |
| 46 | Result approval | ✅ | `POST /results/:id/approve` guarded `APPROVE_EXAM_RESULT` |
| 47 | Result publication | ✅ | `POST /results/exam/:examId/publish` guarded `PUBLISH_EXAM_RESULT`; flips `exam.status`, notifies every published candidate |
| 48 | Result viewing | ✅ | `GET /results/mine`, gated on `publishedAt` |
| 49 | Appeals | ✅ | `POST /results/:id/appeal` — ownership-checked in-service; partial unique index enforces one open appeal per result |
| 50 | Appeal review | ✅ | `POST /results/appeals/:id/decide` guarded `DECIDE_EXAM_APPEAL`; upholding routes the result back to `MARKED` rather than editing the score directly |
| 51 | Retakes | ✅ | `resolveAttempt()` — fully automatic, tamper-proof (not accepted from any client payload) |
| 52 | Exam history | ⚠️ | No dedicated "history" endpoint found; not independently confirmed as a distinct backend concept beyond the registrations/results tables |
| 53 | Notifications | 🟡 | `LicenseNotificationService.notify()` fires on exactly 3 exam events — `EXAM_REGISTERED`, `EXAM_RESULT_PUBLISHED`, `EXAM_APPEAL_DECIDED`. Question submit/review, attendance, incident record/resolve, moderate, approve, and return fire nothing |
| 54 | Certificates, if required | 🔴 | `result.service.ts::publishForExam()` never calls any certificate/PDF service; `completion-effect.service.ts` implements exactly two effects (`REGISTER_SEAFARER`, `REGISTER_VESSEL`) and references neither `Exam` nor `Result` |
---
## 4. Combined Gap Matrix
| # | Requirement | Frontend | Backend | Overall | Gap | Priority |
|---|---|---|---|---|---|---|
| 1 | Create exam | ✅ | 🟡 | 🟡 | Backend: no permission guard on create/edit | **P0** |
| 2 | Configure exam | ✅ | 🟡 | 🟡 | Same permission gap | **P0** |
| 3 | Define exam type | ✅ | ✅ | ✅ | None | — |
| 4 | Define subjects | ✅ | 🟡 | 🟡 | Certification CRUD entirely unguarded server-side | **P0** |
| 5 | Define sections | 🔴 | 🔴 | 🔴 | Entire concept absent, both sides | P2 (only if the product needs multi-part papers) |
| 6 | Configure duration | ✅ | 🟡 | 🟡 | Stored but never enforced against a clock | P1 (blocks Timer, #30) |
| 7 | Configure passing score | ✅ | ✅ | ✅ | None | — |
| 8 | Configure attempts | 🟡 | ✅ (as built) | 🟡 | No admin-configurable cap; frontend is display-only | P2 |
| 9 | Configure exam status | ✅ | ✅ | ✅ | None | — |
| 10 | Configure availability | 🟡 | ⚠️ | ⚠️ | Server-side "open" criteria not independently confirmed | P3 |
| 11 | Create question | ✅ | 🟡 | 🟡 | No permission guard | **P0** |
| 12 | Edit question | ✅ | 🟡 | 🟡 | No permission guard | **P0** |
| 13 | Delete question | ✅ | 🟡 | 🟡 | No permission guard | **P0** |
| 14 | Question bank | ✅ | 🟡 | 🟡 | Unguarded, no status filter for unprivileged callers — full bank incl. unapproved content is readable by any authenticated user | **P0** |
| 15 | Question types | 🟡 | 🟡 | 🟡 | Only two labels exist; no deeper typing | P1 |
| 16 | MCQ options | 🔴 | 🔴 | 🔴 | No data model, both sides | **P0** (blocks #40 automatic grading) |
| 17 | Correct answers | 🔴 | 🔴 | 🔴 | No data model, both sides | **P0** (blocks #40) |
| 18 | Question scoring | ✅ | ✅ | ✅ | None | — |
| 19 | Question ordering | ⚠️ | ⚠️ | ⚠️ | No ordering mechanism confirmed either side | P3 |
| 20 | Random questions | ✅ | 🟡 | 🟡 | No permission guard | **P0** |
| 21 | Paper assembly | ✅ | 🟡 | 🟡 | No permission guard | **P0** |
| 22 | Candidate registration | ✅ | ✅ | ✅ | None | — |
| 23 | Candidate eligibility | ✅ | ✅ | ✅ | None | — |
| 24 | Candidate assignment | 🔴 | 🔴 | 🔴 | No officer-initiated add/remove capability, both sides | P1 |
| 25 | Attendance | ✅ | ✅ | ✅ | None | — |
| 26 | Candidate status | ✅ | ✅ | ✅ | None | — |
| 27 | Exam instructions | 🟡 | ✅ (storage only) | 🟡 | Stored but nothing displays it — downstream of the missing exam-taking flow | P0 (dependent on #2837) |
| 28 | Start exam | 🔴 | 🔴 | 🔴 | Entire capability absent, both sides | **P0** |
| 29 | Create attempt/session | 🔴 | 🔴 | 🔴 | No data model, no API | **P0** |
| 30 | Timer | 🔴 | 🔴 | 🔴 | No enforcement mechanism | **P0** |
| 31 | Question navigation | 🔴 | 🔵 | 🔴 | Nothing to navigate | **P0** |
| 32 | Display questions | 🔴 | 🔴 | 🔴 | No candidate-facing question-serving endpoint | **P0** |
| 33 | Select answers | 🔴 | 🔴 | 🔴 | No data model | **P0** |
| 34 | Save answers | 🔴 | 🔴 | 🔴 | No persistence path | **P0** |
| 35 | Auto-save | 🔴 | 🔴 | 🔴 | Not wired to anything exam-related (reusable pattern exists elsewhere) | **P0** |
| 36 | Resume attempt | 🔴 | 🔴 | 🔴 | No in-progress state to resume | **P0** |
| 37 | Submit exam | 🔴 | 🔴 | 🔴 | No submission event | **P0** |
| 38 | Prevent submission after expiration | 🔴 | 🔴 | 🔴 | No timer to expire against | **P0** |
| 39 | Prevent unauthorized access | 🔵 | 🔵 | 🔵 | Not applicable until #29 exists | — |
| 40 | Automatic grading | 🔴 | 🔴 | 🔴 | Blocked on #16/#17 (MCQ data model) | **P0** |
| 41 | Manual grading | ✅ | 🟡 | 🟡 | No permission guard on create/update | **P0** |
| 42 | Score calculation | ✅ | ✅ | ✅ | None | — |
| 43 | Pass/fail | ✅ | ✅ | ✅ | None | — |
| 44 | Result generation | ✅ | 🟡 | 🟡 | Same permission gap as #41 | **P0** |
| 45 | Result review | ✅ | ✅ | ✅ | None | — |
| 46 | Result approval | ✅ | ✅ | ✅ | None | — |
| 47 | Result publication | ✅ | ✅ | ✅ | None | — |
| 48 | Result viewing | ✅ | ✅ | ✅ | None | — |
| 49 | Appeals | ✅ | ✅ | ✅ | None | — |
| 50 | Appeal review | ✅ | ✅ | ✅ | None | — |
| 51 | Retakes | 🟡 | ✅ | ✅ | Frontend display-only, no gap in function | — |
| 52 | Exam history | 🟡 | ⚠️ | 🟡 | No dedicated view/endpoint beyond existing tables | P3 |
| 53 | Notifications | ⚠️ | 🟡 | 🟡 | Only 3 of ~8 exam events notify | P2 |
| 54 | Certificates, if required | 🔴 | 🔴 | 🔴 | No link from a published PASSED result to certificate issuance, either side | P1 (pending product decision — "if required") |
---
## 5. Exam System Deep-Dive
The full lifecycle breakdown is §§14 above (every row already carries Frontend / Backend / Overall / Evidence / Gap). No separate restatement is needed — this section confirms the six categories were each covered in full: **Exam Administration** (10/10 items), **Question Management** (11/11), **Candidate Management** (5/5), **Candidate Exam-Taking** (13/13), **Evaluation** (8/8), **Post-Exam** (7/7) — 54/54.
---
## 6. Important Existing Exam Findings — Explicit Verification
**Candidate exam-taking.** Verified by direct search, not assumption: no code exists anywhere in either repository for starting an exam, an exam attempt/session entity or table, a timer, answer capture, answer persistence, auto-save, or submission (requirements #2838). `Exam.givenTime`/`Question.time` are stored durations that are never read against a clock. **The entire candidate-facing online exam flow is missing, in full**, on both frontend and backend.
**MCQ structure.** Verified absent at the schema level (not merely the UI level): `Question`'s entity, its create/update DTOs, and the underlying `questions` table (base migration + the one later ALTER) carry no `options`, `choices`, `correctAnswer`, or `answerKey` field. `EExamForm.CHOICE` is a category label only. **Automatic MCQ grading cannot be implemented correctly without first changing the underlying domain/database model** — confirmed as a hard schema prerequisite, not a UI-only gap.
**Automatic grading.** Verified the backend has no automatic grading logic. `result.service.ts::grade()` sums whatever numeric marks an officer typed into `resultBreakdowns`; it never inspects a candidate's answer, because none is ever stored. Manual entry is the only grading path that exists, for every question regardless of its declared `form` label.
**Manual grading.** Confirmed complete and real end-to-end on the *logic* side: per-question scoring (bounded `[0, points]` on the frontend), total-score computation (server-side sum/average/percentage), pass/fail (server-computed against `cuttingPoint`, frontend cannot override), a full review chain (MARKED→MODERATED→APPROVED→PUBLISHED, each transition remark-required where relevant), and publication (fires a real per-candidate notification, gates candidate visibility on `publishedAt`). The one defect is not in the logic — it's that the **initial mark-entry route itself (`POST /results`) carries no permission guard**.
**Exam permissions.** Sharply two-tier — this is the analysis's central finding:
- **Correctly guarded** (a non-privileged authenticated user gets a real `403`): question review/approve (`APPROVE_QUESTION`), attendance (`RECORD_EXAM_ATTENDANCE`), incident management (`MANAGE_EXAM_INCIDENTS`), result moderate/approve/publish (`MODERATE_/APPROVE_/PUBLISH_EXAM_RESULT`), appeal decisions (`DECIDE_EXAM_APPEAL`).
- **Not guarded at all** — reachable by any authenticated account, including a portal applicant with no staff position: create/edit/delete an exam (#1/#2), create/edit/delete a question (#1113), browsing the full question bank including unapproved content (#14), assembling or randomly drawing a live exam paper (#20/#21), and **recording or rewriting a candidate's result** (#41/#44) — despite the `RECORD_EXAM_RESULT` permission constant existing and even being applied to unrelated *read* routes in the same controller. Certification (#4, "subjects") shares the same root cause via an unguarded generic CRUD scaffold.
On the frontend, no exam-admin page calls `usePermissions()` at all and the exam nav group carries no permission requirement — so today nothing on either side stops an authenticated candidate account from directly calling the API to alter exam configuration, tamper with the question bank, or fabricate a result.
---
## 7. Frontend ↔ Backend Contract Gaps
| Requirement | Frontend State | Backend State | Contract Gap |
|---|---|---|---|
| Start exam / attempt / timer / answers / auto-save / submit (#2838) | Missing | Missing | Both missing — new end-to-end feature, blocked on a data-model decision first (see §14) |
| MCQ options / correct answers (#16, #17) | Missing | Missing | Both missing — schema prerequisite for #40 |
| Certificates on pass (#54) | Missing (wrong-domain page shares the name) | Missing | Both missing — needs a product decision on reuse-vs-new-build |
| Candidate assignment by an officer (#24) | Missing | Missing | Both missing |
| Question bank browsing (#14) | Assumes a curated/approved list | Returns everything, unguarded | Frontend implicitly relies on server-side filtering/guarding that doesn't exist |
| Configure attempts (#8) | Missing (display-only) | Exists, but fully automatic with no admin-facing "cap" concept | Backend has the logic; there is no configuration surface on either side because the backend never exposes one |
| Manual grading, result generation (#41, #44) | Complete | Logic complete, route unguarded | Backend security gap underneath an otherwise-complete frontend flow |
---
## 8. Data Model Gaps
Confirmed against every migration touching an exam table (`1785742151690-EmaBaseline.ts`, `1785749000000-examRegistration.ts`, `1785750000000-examOperations.ts` — no other migration touches these tables) and every corresponding entity file:
| Concept | Current state | Why insufficient | Requirement(s) blocked |
|---|---|---|---|
| Exam | `exams` table, full administrative metadata | Sufficient for the administrative workflow as built | #14, 6, 7, 9 |
| Exam sections | Does not exist | Nothing partitions an exam's question set | #5 |
| Questions | `questions` table — title, form, points, time, QC status | No options, no correct answer | #16, #17, #40 |
| Question options/choices | Does not exist — no child table, no jsonb column | Blocks #16, and any candidate-facing question display | #16, #32, #40 |
| Correct answer / answer key | Does not exist | Blocks automatic comparison of a candidate's answer | #17, #40 |
| Exam attempts / sessions | Does not exist. `ExamRegistration.attemptNumber` is a *retake counter*, not a live in-progress session — no start/submit timestamp, no state machine | Nothing represents a candidate actively sitting an exam | #28, #29, #36, #37, #39 |
| Candidate answers | Does not exist | `Result.resultBreakdowns` stores only the *outcome* (a score), never what was actually answered | #33, #34, #35, #40 |
| Submission | Does not exist (no exam-attempt "submitted" event, distinct from the unrelated license-application "submit") | Nothing marks an attempt finished from the candidate's side | #37, #38 |
| Scores | `Result.resultBreakdowns` + `totalScore` | Sufficient for the manual-grading workflow as built | #18, #42 |
| Results | `results` table with a full QC-chain of columns | Sufficient, well-designed | #4447 |
| Retakes | `ExamRegistration.kind`/`attemptNumber`/`previousResultId`, server-derived | Sufficient | #51 |
| Exam status | `EExamStatus` enum | Sufficient | #9 |
| Exam timing | `Exam.givenTime`/`Question.time` — informational jsonb only | Never enforced against a clock; no "time remaining"/deadline field | #6, #30, #38 |
No new schema is proposed here, per this task's scope — only what's missing and which requirement it blocks.
---
## 9. Existing Functionality That Should NOT Be Rebuilt
- **Exam creation/configuration** (#14, 6, 7, 9) — solid on both sides; only needs its permission guard added, not a rewrite.
- **Question-bank QC workflow** and **question CRUD** (#1115, 18) — the DRAFT→PENDING_APPROVAL→APPROVED/REJECTED/RETIRED chain is genuinely well-modeled; same fix (permission guard), not a rebuild.
- **Question assignment**, both manual and server-side random draw (#20, #21) — correct logic (random mode filters APPROVED-only, commits server-side without shipping the pool to the browser); guard the routes, don't rewrite them.
- **Candidate registration and eligibility** (#22, #23) — real, ownership-scoped, correctly enforces the ACTIVE-seafarer and already-passed-subject rules.
- **Attendance and incident logging** (#25, #26 and the incident panel) — complete and correctly guarded already.
- **Retake derivation** (#8 as implemented, #51) — one of the best-engineered parts of the system: `resolveAttempt()` is tamper-proof, server-only, sourced from the candidate's own published-result history. Preserve exactly as-is.
- **Manual grading, moderation, approval, publication chain** (#4147) — the pipeline itself, its client-preview-but-server-authoritative pass/fail computation, and its publish-time notification are all sound. Only the initial create/update-result guard needs fixing.
- **Appeals** (#49, #50) — complete, correctly guarded, and the "uphold sends it back to re-marking rather than editing the score directly" design is sound.
---
## 10. Bugs vs. Missing Features vs. Incomplete Features
### Existing Bugs (functionality exists, but is incorrect)
- No permission guard on `POST/PUT/DELETE /exams` (#1, #2), `POST/PUT/DELETE /questions` (#1113), `GET /questions` (#14), `POST /exams/:id/questions[/random]` (#20, #21), `POST/PUT /results` (#41, #44) — any authenticated user, including a portal applicant, can alter exam configuration, the question bank, paper assembly, and candidate results.
- No permission guard on `certification` CRUD (#4) — same root cause (unguarded generic `CommonCrudController`).
### Missing Features (no meaningful implementation on either side)
- Exam sections (#5).
- The entire candidate exam-taking flow: start, attempt/session, timer, navigation, question display, answer selection/save/auto-save, resume, submit, expiry enforcement (#2838).
- MCQ options and correct-answer/answer-key data model (#16, #17).
- Automatic grading (#40) — blocked on the above.
- Officer-initiated candidate assignment (#24).
- Exam-pass certificate issuance (#54).
### Incomplete Features (meaningful implementation exists, gaps remain)
- Question types (#15) — two labels exist, nothing deeper.
- Configure duration/timing (#6) — captured but never enforced (direct consequence of the missing attempt/timer model, not a separate defect).
- Configure attempts (#8) — fully automatic derivation exists; no admin-facing cap/configuration surface.
- Exam instructions (#27) — captured on creation, no display surface downstream.
- Notifications (#53) — 3 of roughly 8 relevant exam events fire; the rest are silent.
- Exam history (#52) — no dedicated view beyond the existing registrations/results tables.
- Configure availability (#10) and question ordering (#19) — present in some form, exact criteria/mechanism not confirmed either side (⚠️ Needs Verification, not a confirmed bug or confirmed absence).
---
## 11. Permission Gaps
| Action | Frontend Permission Check | Backend Permission Check | Status |
|---|---|---|---|
| Create/edit/delete exam (#1, #2) | None (`usePermissions()` not used on exam pages) | None | **Full-stack gap** |
| Create/edit/delete question (#1113) | None | None | **Full-stack gap** |
| Browse question bank (#14) | None | None | **Full-stack gap** |
| Assign/draw exam paper (#20, #21) | None | None | **Full-stack gap** |
| Record/edit exam result (#41, #44) | None | None (despite `RECORD_EXAM_RESULT` existing) | **Full-stack gap** |
| Define subjects — certification CRUD (#4) | None | None | **Full-stack gap** |
| Review/approve question | None (client-side) | `APPROVE_QUESTION` | Backend enforces; frontend gap is cosmetic only |
| Record attendance (#25) | None (client-side) | `RECORD_EXAM_ATTENDANCE` | Backend enforces; cosmetic frontend gap |
| Manage exam incidents | None (client-side) | `MANAGE_EXAM_INCIDENTS` | Backend enforces; cosmetic frontend gap |
| Moderate/approve/publish result (#4547) | None (client-side) | `MODERATE_/APPROVE_/PUBLISH_EXAM_RESULT` | Backend enforces; cosmetic frontend gap |
| Decide exam appeal (#50) | None (client-side) | `DECIDE_EXAM_APPEAL` | Backend enforces; cosmetic frontend gap |
**Inconsistent permission names:** none found — every guard that exists uses matching string keys between the frontend's `PERMISSIONS` constant and the backend's `LICENSE_PERMISSIONS` constant, for the (small) subset of exam actions the frontend mirrors at all. The frontend simply never mirrors the exam permission keys anywhere, which is why the unguarded backend routes have no corresponding client-side check to be inconsistent with.
**Pattern worth noting:** where the backend *does* have a guard, the frontend's absence of a matching client-side check is harmless (a 403 after the fact) — not a security hole, just a UX rough edge. The five "Full-stack gap" rows above are the only ones that represent an actual, currently-exploitable authorization bypass.
---
## 12. Overall User-Story Completion
**Method:** every one of the 54 numbered requirements in §1 was independently traced on both frontend and backend to file:line depth (§§24) — this percentage is a direct count of that table, not an estimate.
- **✅ Fully Complete (both sides, no gap):** 27 of 54 = **50%**#3, 7, 9, 18, 22, 23, 25, 26, 42, 43, 45, 46, 47, 48, 49, 50, 51 (function complete; frontend display-only doesn't block it), plus 10 more counted at the "logic is right, no gap" bar.
*(Exact list: 3, 7, 9, 18, 22, 23, 25, 26, 42, 43, 45, 46, 47, 48, 49, 50, 51 = 17 rows marked outright ✅/✅/✅ with "None" in the Gap column. Recount precisely: rows with Overall ✅ in §4 are #3, 7, 9, 18, 22, 23, 25, 26, 42, 43, 45, 46, 47, 48, 49, 50, 51 — **17 of 54 = 31%**.)*
- **🟡 Partially Complete (meaningful implementation, real gap remains):** #1, 2, 4, 6, 8, 10, 11, 12, 13, 14, 15, 20, 21, 27, 41, 44, 52, 53 = **18 of 54 = 33%**.
- **🔴 Missing (no meaningful implementation on either side):** #5, 16, 17, 24, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 40, 54 = **17 of 54 = 31%**.
- **⚠️ Needs Verification:** #19 = **1 of 54**; #10 is already counted in Partial since a real (if unconfirmed) route exists.
- **🔵 Not Applicable:** #39 = **1 of 54**.
(17 + 18 + 17 + 1 + 1 = 54 — the count reconciles.)
---
## 13. Priority Gap List
### P0 — Critical
- Add permission guards to `POST/PUT/DELETE /exams`, `POST/PUT/DELETE /questions`, `GET /questions`, `POST /exams/:id/questions[/random]`, `POST/PUT /results`, and `certification` CRUD (#1, 2, 4, 1114, 20, 21, 41, 44) — currently any authenticated user can tamper with exam configuration, the question bank, and candidate results.
- Product decision: is online candidate exam-taking (#2838) in scope at all? Everything else in the Candidate Exam-Taking category depends on this answer.
- MCQ options/correct-answer data model (#16, #17) — schema prerequisite for automatic grading (#40) and any future exam-taking UI.
### P1 — High
- Exam question-type depth beyond ESSAY/CHOICE labels, if richer types are wanted (#15).
- Officer-initiated candidate assignment (#24).
- Exam-pass → certificate issuance (#54), pending a product decision on scope ("if required").
- Duration/timing enforcement (#6) — meaningful only once #2838 are scoped.
### P2 — Medium
- Admin-configurable attempt cap, if wanted beyond the current automatic/uncapped behavior (#8).
- Round out exam-event notification coverage (#53).
- Exam sections, if the product needs multi-part papers (#5).
### P3 — Low
- Confirm/implement explicit question ordering (#19).
- Confirm exam-availability criteria (#10).
- Dedicated exam-history view (#52), if wanted beyond the existing tables.
---
## 14. Recommended Implementation Order
1. **Backend permission fixes first.** Isolated, low-risk, no business-logic change — adding an existing, already-proven guard decorator to routes that already work correctly otherwise. This closes the worst exposure (§6/§11) before anything else is touched, and has zero dependency on any other item below.
2. **Product decision on candidate exam-taking scope.** Every remaining item in the Candidate Exam-Taking category (#2839) and Automatic grading (#40) depends on knowing whether online delivery is being built at all, versus the system's current shape (administer on paper, record results digitally). Building past this point before the decision risks the wrong thing.
3. **MCQ / answer-key domain model**, once (2) confirms it's needed — a backend schema and entity change that both #40 (automatic grading) and any exam-taking UI (#32, #33) depend on; nothing above it should be attempted first.
4. **Backend attempt/session + answer-capture APIs**, once (3) exists — informed by the reusable per-section-autosave pattern already proven in the licensing wizard (`PATCH /license-applications/:id/sections/:sectionKey`), which is the closest existing precedent for #35.
5. **Frontend exam-taking flow** (#27, 28, 3139), once (4) has real endpoints to call.
6. **Automatic grading logic** (#40), layered on (3)'s answer key and (4)'s captured answers — can proceed in parallel with (5) once (3)/(4) exist, since grading only needs stored answers to compare against, not the live UI.
7. **Officer-initiated candidate assignment (#24) and certificate issuance (#54)** — independent of the exam-taking track; sequenced here only by priority (P1), not by any technical dependency, so either can be picked up any time after step 1.
8. **Secondary items** — client-side permission-check polish, notification coverage, sections, history, ordering, availability criteria — none of these block anything else.
---
## 15. Final Executive Summary
### Current State
The system runs a complete, real administrative *paper trail* for examinations: create/configure an exam, build and quality-control a question bank, assemble a paper (manually or by random server-side draw), register candidates, take attendance, log incidents, enter marks by hand, run them through a four-stage review/approval/publication pipeline, and handle appeals. What it cannot do is let a candidate actually take the exam through the software — there is no start, timer, question display, answer capture, or submission anywhere in either repository.
### Main Strengths
Retake derivation, the marking/moderation/approval/publication chain, attendance/incident logging, and candidate registration/eligibility are all genuinely well-built on both sides and should be preserved as-is (§9).
### Main Gaps
The entire candidate exam-taking flow (§6); the MCQ options/correct-answer data model that automatic grading depends on; officer-initiated candidate assignment; exam-pass certificate issuance; and a cluster of exam-domain (and certification) write endpoints with no permission guard at all.
### Biggest Exam Gap
**The candidate exam-taking experience does not exist — nothing starts an attempt, runs a timer, captures an answer, auto-saves, or submits a completed exam, on either side of the stack.** This is the single largest gap in the entire examination workflow, and every downstream item (automatic grading, exam instructions display, submission-expiry handling) depends on it.
### Domain/Data Model Gaps
No exam-attempt/session entity, no candidate-answer storage, no question-option/correct-answer schema (§8). These three are prerequisites — nothing in the exam-taking or automatic-grading categories can be built correctly without them existing first.
### Frontend Gaps
The entire exam-taking UI (#2739); officer-initiated candidate roster management (#24); certificate issuance UI (#54); client-side permission gating on the several exam actions the backend leaves entirely open.
### Backend Gaps
Missing permission guards on exam/question/result write routes and certification CRUD (§6/§11); entirely missing exam-attempt/session infrastructure, answer capture, automatic grading, and certificate issuance; no officer-initiated registration endpoint.
### Critical Permission Gaps
Any authenticated user — including a portal applicant holding no staff position — can today, via direct API calls: create, edit, or delete an exam; create, edit, delete, or browse the full question bank (unapproved content included); assemble the live question set for an exam; and record or rewrite a candidate's result. The same root cause (an unguarded generic CRUD scaffold) leaves the certification/"subjects" catalog open to the same treatment. This is the most consequential finding in this analysis and should be treated as more urgent than any feature-completeness gap.
### Recommended Next Step
Add the missing `PermissionGuard` decorators to the exam/question/result write routes and the `certification` CRUD controller. This is the single most logical next step: it is low-risk (an existing, already-proven mechanism applied to existing routes, no new logic), it closes the most severe finding in this report, and — unlike the exam-taking feature — it requires no upstream product decision to begin. (Not implemented here, per this task's read-only scope.)

View File

@@ -1,10 +1,11 @@
import { fetchBaseQuery, type BaseQueryFn } from '@reduxjs/toolkit/query/react';
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { resolveSessionContext } from '../session';
import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { resolveSessionContext } from "../session";
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3001/api";
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;
@@ -28,7 +29,7 @@ export const baseQueryWithReauth: BaseQueryFn<
const { token, sessionHeaders } = resolveSessionContext(
api.getState() as { auth?: { token?: string } },
);
if (token) headers.set('Authorization', `Bearer ${token}`);
if (token) headers.set("Authorization", `Bearer ${token}`);
Object.entries(sessionHeaders).forEach(([k, v]) => headers.set(k, v));
return headers;
},

View File

@@ -73,8 +73,8 @@ async function runRefresh(): Promise<string> {
// The IAM service exposes this as `refresh-token`; posting to `/auth/refresh`
// 404s, which the caller would turn into a silent logout.
const response = await fetch(`${BASE_API_URL}/auth/refresh-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import {
Tooltip,
TextInput,
UnstyledButton,
rem,
@@ -37,6 +38,9 @@ export function BilingualInput({
...rest
}: BilingualInputProps) {
const [lang, setLang] = useState<'en' | 'am'>('en');
const otherLang = lang === 'en' ? 'am' : 'en';
const otherLangName = otherLang === 'en' ? 'English' : 'Amharic';
const otherIsEmpty = !value[otherLang]?.trim();
const toggle = () => setLang((l) => (l === 'en' ? 'am' : 'en'));
@@ -48,17 +52,22 @@ export function BilingualInput({
value={value[lang]}
onChange={(e) => onChange({ ...value, [lang]: e.currentTarget.value })}
rightSection={
<Tooltip
label={`Switch to ${otherLangName}${otherIsEmpty ? ' — empty' : ''}`}
withArrow
>
<UnstyledButton
onClick={toggle}
aria-label={`Switch to ${lang === 'en' ? 'Amharic' : 'English'}`}
aria-label={`Switch to ${otherLangName}`}
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(28),
height: rem(20),
width: rem(32),
height: rem(24),
borderRadius: rem(4),
fontSize: rem(10),
fontSize: rem(11),
fontWeight: 700,
letterSpacing: '0.05em',
background:
@@ -74,11 +83,28 @@ export function BilingualInput({
}}
>
{lang === 'en' ? 'EN' : 'AM'}
{otherIsEmpty && (
<span
aria-hidden
title={`${otherLangName} text is missing`}
style={{
position: 'absolute',
top: -2,
right: -2,
width: rem(7),
height: rem(7),
borderRadius: '50%',
background: 'var(--mantine-color-red-6)',
border: '1px solid var(--mantine-color-body)',
}}
/>
)}
</UnstyledButton>
</Tooltip>
}
styles={{
input: {
paddingRight: rem(42),
paddingRight: rem(46),
},
}}
{...rest}