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; +}