mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-02 06:53:40 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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: "ተመርምሯል",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type {
|
||||
ExamStateView,
|
||||
AppNotification,
|
||||
ApplicationDetail,
|
||||
ApplicationKind,
|
||||
@@ -649,6 +650,18 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* The examination leg behind one application, as the server reads it.
|
||||
*
|
||||
* The COC detail page used to infer this from the application status
|
||||
* alone, which is how it kept offering "Schedule exam" for a paper the
|
||||
* candidate had already sat and passed.
|
||||
*/
|
||||
getExamStateForApplication: builder.query<ExamStateView, string>({
|
||||
query: (id) => ({ url: `/exams/applications/${id}/state` }),
|
||||
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
// No `getEligibleExams`, `scheduleExam` or `recordExamOutcome`: the
|
||||
// endpoints behind them are gone. Sittings are published as master
|
||||
// schedules and candidates register for one themselves, and an outcome
|
||||
@@ -1053,6 +1066,7 @@ export const {
|
||||
useUpdateLicenseValidityMutation,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
useCreateApplicationMutation,
|
||||
useGetExamStateForApplicationQuery,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetApplicationQuery,
|
||||
useInitiatePaymentMutation,
|
||||
|
||||
@@ -748,3 +748,36 @@ export interface IssuedLicense {
|
||||
certificateFileKey: string | null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Where a certificate application stands in the examination leg, derived
|
||||
* server-side from the registration, the attempt and the published mark
|
||||
* (ExamStateService). The application status alone cannot answer this — which
|
||||
* is why the back office could show "awaiting exam" over a published pass.
|
||||
*/
|
||||
export type ExamState =
|
||||
| 'NOT_REGISTERED'
|
||||
| 'REGISTERED'
|
||||
| 'ATTENDANCE_CONFIRMED'
|
||||
| 'NOT_SITTING'
|
||||
| 'IN_PROGRESS'
|
||||
| 'UNDER_EVALUATION'
|
||||
| 'PASSED'
|
||||
| 'FAILED';
|
||||
|
||||
export interface ExamStateView {
|
||||
state: ExamState;
|
||||
registrationId: string | null;
|
||||
admissionNumber: string | null;
|
||||
examId: string | null;
|
||||
examTitle: { en?: string; am?: string } | null;
|
||||
examDate: string | null;
|
||||
attendanceStatus: string | null;
|
||||
attemptStatus: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' | null;
|
||||
/** Only once the mark is published — before that it is not the candidate's. */
|
||||
score: number | null;
|
||||
outcome: 'PASSED' | 'FAILED' | null;
|
||||
publishedAt: string | null;
|
||||
/** Whether the exam engine produced the mark, or a person did. */
|
||||
autoGraded: boolean | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user