From ee64856983af4b19a4fa9dae4bf9021ec3a99553 Mon Sep 17 00:00:00 2001 From: mihretue Date: Mon, 31 Aug 2026 08:04:56 +0000 Subject: [PATCH] fix(exam): show the back office the real examination state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/ExamStatePanel.tsx | 100 ++++++++++++++++++ .../pages/LicenseReviewPage/index.tsx | 15 +++ apps/backoffice/src/app/i18n/locales/am.ts | 21 ++++ apps/backoffice/src/app/i18n/locales/en.ts | 21 ++++ .../lib/features/licensing/licensing-api.ts | 14 +++ .../lib/features/licensing/licensing.types.ts | 33 ++++++ 6 files changed, 204 insertions(+) create mode 100644 apps/backoffice/src/app/features/license-review/components/ExamStatePanel.tsx diff --git a/apps/backoffice/src/app/features/license-review/components/ExamStatePanel.tsx b/apps/backoffice/src/app/features/license-review/components/ExamStatePanel.tsx new file mode 100644 index 000000000..f46da8080 --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/components/ExamStatePanel.tsx @@ -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 = { + 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 ( +
+ + {label} + + {value || '—'} +
+ ); +} + +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 ( + + + + + {t('review.exam.title', 'Examination')} + + + {t(`review.exam.state.${data.state}`, data.state)} + + + + + + + + {settled && ( + <> + + + {/* Says where the mark came from, so nobody re-enters one the + engine already produced. */} + + + )} + + + ); +} diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index 8551f3cca..d1641f0dc 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -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() { )} + {/* Examined certificates only — null for everything else. */} + {EXAM_LEG_STATUSES.includes(app.status) && ( + + )} + {t("review.statusTimeline", "Progress")} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 8f47f9a76..01bb9be02 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -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: "ተመርምሯል", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index b8fb74515..c7f30432f 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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', diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index e87a54d7f..38595d1e0 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -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({ + 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, diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 1d3779193..c60425a6b 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -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; +}