fix(exam): show the back office the real examination state

The COC detail page read the application status alone, so a candidate who had
registered, been marked present, sat the paper and had a PASSED mark published
still appeared to be awaiting an exam — and the officer was offered "Schedule
exam" and "Record exam result" for work already done.

ExamStatePanel reads GET /exams/applications/:id/state, which the server
derives from the registration, the attempt and the published mark. Session,
date and admission number while the exam is ahead; score, outcome and whether
the exam engine or an examiner produced the mark once it is settled. Polled,
because attendance is recorded and papers are marked while the officer has the
page open. Same derivation the applicant's portal reads, so the two cannot
disagree.

The eligible-exams query, the scheduling mutation and the outcome mutation are
gone from the RTK layer along with the endpoints behind them.
This commit is contained in:
mihretue
2026-08-31 08:04:56 +00:00
parent 6f4878d300
commit ee64856983
6 changed files with 204 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
import { Badge, Group, Paper, SimpleGrid, Text, Title } from '@mantine/core';
import { IconClipboardCheck } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useGetExamStateForApplicationQuery, type ExamState } from '@ema-platform/api';
/**
* What the candidate's examination actually shows, on the officer's screen.
*
* The detail page used to read the application status alone, so a candidate
* who had registered, been marked present, sat the paper and had a PASSED mark
* published still appeared to be awaiting an exam — and the officer was still
* offered "Schedule exam" and "Record exam result" for work already done. The
* state here is derived server-side (ExamStateService) from the registration,
* the attempt and the published mark, so this panel and the applicant's portal
* cannot disagree.
*/
const STATE_COLOR: Record<ExamState, string> = {
NOT_REGISTERED: 'gray',
REGISTERED: 'cyan',
ATTENDANCE_CONFIRMED: 'indigo',
NOT_SITTING: 'orange',
IN_PROGRESS: 'blue',
UNDER_EVALUATION: 'yellow',
PASSED: 'teal',
FAILED: 'red',
};
function Row({ label, value }: { label: string; value?: string | null }) {
return (
<div>
<Text fz="xs" c="dimmed">
{label}
</Text>
<Text fz="sm">{value || '—'}</Text>
</div>
);
}
export function ExamStatePanel({ applicationId }: { applicationId: string }) {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
// Polled: attendance is recorded by an invigilator, and a paper is marked
// and published, while the officer has this page open.
const { data, isLoading } = useGetExamStateForApplicationQuery(applicationId, {
pollingInterval: 30_000,
refetchOnFocus: true,
});
if (isLoading || !data) return null;
const settled = data.outcome !== null;
return (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
<Group gap="xs">
<IconClipboardCheck size={17} />
<Title order={5}>{t('review.exam.title', 'Examination')}</Title>
</Group>
<Badge color={STATE_COLOR[data.state]} variant="light">
{t(`review.exam.state.${data.state}`, data.state)}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
<Row
label={t('review.exam.session', 'Session')}
value={data.examTitle?.[locale] ?? data.examTitle?.en}
/>
<Row label={t('review.exam.date', 'Exam date')} value={data.examDate} />
<Row
label={t('review.exam.admission', 'Admission number')}
value={data.admissionNumber}
/>
{settled && (
<>
<Row
label={t('review.exam.score', 'Score')}
value={data.score === null ? null : String(data.score)}
/>
<Row
label={t('review.exam.outcome', 'Outcome')}
value={t(`review.exam.state.${data.outcome}`, data.outcome ?? '')}
/>
{/* Says where the mark came from, so nobody re-enters one the
engine already produced. */}
<Row
label={t('review.exam.source', 'Result source')}
value={
data.autoGraded
? t('review.exam.sourceEngine', 'Exam engine')
: t('review.exam.sourceExaminer', 'Examiner')
}
/>
</>
)}
</SimpleGrid>
</Paper>
);
}

View File

@@ -93,6 +93,7 @@ import { ActivityRail } from "../../components/ActivityRail";
import { DocumentsTab } from "../../components/DocumentsTab";
import { FormDetailsTab } from "../../components/FormDetailsTab";
import { ApplicantCard } from "../../components/ApplicantCard";
import { ExamStatePanel } from "../../components/ExamStatePanel";
import { useGetLocationsQuery } from "../../../location/api/location-api";
import { computeSla } from "../../sla";
import { reviewStaffColumns } from "./columns";
@@ -159,6 +160,15 @@ function buildChecklist(
* Decision Bar pinned to the bottom, so an officer can act from any scroll
* position instead of scrolling back to a column of buttons.
*/
/** The examination leg — the only statuses the exam panel has anything to say about. */
const EXAM_LEG_STATUSES: string[] = [
"EXAM_PAYMENT_PENDING",
"EXAM_PAID",
"EXAM_SCHEDULED",
"EXAM_PASSED",
"EXAM_FAILED",
];
export function LicenseReviewPage() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
@@ -1010,6 +1020,11 @@ export function LicenseReviewPage() {
</Paper>
)}
{/* Examined certificates only — null for everything else. */}
{EXAM_LEG_STATUSES.includes(app.status) && (
<ExamStatePanel applicationId={id} />
)}
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
{t("review.statusTimeline", "Progress")}

View File

@@ -697,6 +697,27 @@ export const am: Translations = {
allExams: "ሁሉም ፈተናዎች",
},
review: {
exam: {
title: "ፈተና",
session: "ክፍለ ጊዜ",
date: "የፈተና ቀን",
admission: "የመግቢያ ቁጥር",
score: "ውጤት",
outcome: "ውሳኔ",
source: "የውጤት ምንጭ",
sourceEngine: "የፈተና ሞተር",
sourceExaminer: "ፈታኝ",
state: {
NOT_REGISTERED: "አልተመዘገበም",
REGISTERED: "ተመዝግቧል — መገኘት በመጠባበቅ ላይ",
ATTENDANCE_CONFIRMED: "መገኘት ተረጋግጧል",
NOT_SITTING: "አይፈተንም",
IN_PROGRESS: "ፈተና በመካሄድ ላይ",
UNDER_EVALUATION: "ተጠናቋል — በግምገማ ላይ",
PASSED: "አልፏል",
FAILED: "አላለፈም",
},
},
column: "ግምገማ",
MARKED: "ተስተካክሏል",
MODERATED: "ተመርምሯል",

View File

@@ -697,6 +697,27 @@ export const en = {
allExams: 'All Exams',
},
review: {
exam: {
title: 'Examination',
session: 'Session',
date: 'Exam date',
admission: 'Admission number',
score: 'Score',
outcome: 'Outcome',
source: 'Result source',
sourceEngine: 'Exam engine',
sourceExaminer: 'Examiner',
state: {
NOT_REGISTERED: 'Not registered',
REGISTERED: 'Registered — awaiting attendance',
ATTENDANCE_CONFIRMED: 'Attendance confirmed',
NOT_SITTING: 'Not sitting',
IN_PROGRESS: 'Exam in progress',
UNDER_EVALUATION: 'Completed — under evaluation',
PASSED: 'Passed',
FAILED: 'Failed',
},
},
column: 'Review',
MARKED: 'Marked',
MODERATED: 'Moderated',