Merge pull request #52 from Tria-plc/fix/coc-exam-workflow-defects

Fix/coc exam workflow defects
This commit is contained in:
Mihretu Endeshaw
2026-08-31 14:39:41 +03:00
committed by GitHub
22 changed files with 824 additions and 374 deletions

View File

@@ -11,8 +11,9 @@ import {
Card,
Select,
NumberInput,
Tabs,
Stepper,
SimpleGrid,
Paper,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
@@ -20,6 +21,7 @@ import {
IconPlus,
IconInfoCircle,
IconClipboardList,
IconChecklist,
} from "@tabler/icons-react";
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
@@ -32,10 +34,28 @@ import {
useDeleteExamMutation,
} from "../../api/exam-api";
import type { Exam } from "../../types/exam";
import {
LAST_STEP,
stepOfError,
validateAll,
validateStep,
type ExamFormValues,
} from "./validation";
import { examColumns } from "./columns";
import { examActionsColumn } from "./actions";
import { ErrorState, PageHeader } from '@ema-platform/ui';
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div>
<Text fz="xs" c="dimmed">
{label}
</Text>
<Text fz="sm">{value || "—"}</Text>
</div>
);
}
function ExamForm({
editing,
certOptions,
@@ -77,45 +97,48 @@ function ExamForm({
editing?.cuttingPoint ?? 0,
);
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
const [activeTab, setActiveTab] = useState<string | null>("basic");
const [step, setStep] = useState(0);
/**
* Split per tab so "Next" can check just the tab in front of the user.
* Submitting from Basic Info used to complain about Settings fields the
* user had not been shown yet — the error was correct and unactionable at
* the same time. Each returns the message key for what is missing, or null.
* Validation is per step, so "Next" only ever complains about what is in
* front of the user. Submitting from Basic Info used to report the Settings
* fields as missing — correct and unactionable at the same time, since that
* step had not been shown yet. The rules live in ./validation so the
* per-step check and the final one cannot drift apart.
*/
const validateBasic = (): string | null => {
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
return "exam.form.fillRequiredBasic";
}
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
return "exam.form.directionBothLanguages";
}
return null;
const values: ExamFormValues = {
certificationId,
titleEn,
titleAm,
directionEn,
directionAm,
date,
venue,
type,
form,
adminMethod,
evalMethod,
cuttingPoint,
};
const validateSettings = (): string | null =>
!type || !form || !adminMethod || !evalMethod || !cuttingPoint
? "exam.form.fillRequiredSettings"
: null;
const goNext = () => {
const error = validateBasic();
const error = validateStep(step, values);
if (error) {
notify.error(t(error));
return;
}
setActiveTab("settings");
setStep((current) => current + 1);
};
/** No validation going backwards — state lives in this component, so
* nothing entered is lost either way. */
const goBack = () => setStep((current) => Math.max(0, current - 1));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Still checks both: the tabs are clickable, so a user can reach Settings
// without going through Next.
const error = validateBasic() ?? validateSettings();
const error = validateAll(values);
if (error) {
setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic");
setStep(stepOfError(error));
notify.error(t(error));
return;
}
@@ -146,20 +169,23 @@ function ExamForm({
return (
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
<form onSubmit={handleSubmit}>
<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")}
</Tabs.Tab>
<Tabs.Tab
value="settings"
leftSection={<IconClipboardList size={15} />}
>
{t("exam.form.settings")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="basic">
{/* Stepper, not Tabs: the form is a sequence with a submit at the end,
and the header has to say which step of how many rather than
offering all of them at once. `allowStepSelect` on visited steps
only — jumping ahead is what let the old form be submitted from
Basic Info. */}
<Stepper
active={step}
onStepClick={setStep}
size="sm"
radius="md"
mb="md"
>
<Stepper.Step
label={t("exam.form.basicInfo")}
icon={<IconInfoCircle size={15} />}
allowStepSelect={step > 0}
>
<Stack gap="sm">
<Select
label={t("exam.form.certification")}
@@ -249,9 +275,13 @@ function ExamForm({
/>
</Group>
</Stack>
</Tabs.Panel>
</Stepper.Step>
<Tabs.Panel value="settings">
<Stepper.Step
label={t("exam.form.settings")}
icon={<IconClipboardList size={15} />}
allowStepSelect={step > 1}
>
<Stack gap="sm">
<SimpleGrid cols={2} spacing="sm">
<Select
@@ -353,32 +383,89 @@ function ExamForm({
/>
)}
</Stack>
</Tabs.Panel>
</Tabs>
</Stepper.Step>
{/* Read-only by design: the last step is for checking what is about
to be created, not a third place to edit it. Back returns to the
step that owns any field that looks wrong. */}
<Stepper.Step
label={t("exam.form.review")}
icon={<IconChecklist size={15} />}
allowStepSelect={false}
>
<Stack gap="xs">
<Text fz="sm" c="dimmed">
{t("exam.form.reviewHint")}
</Text>
<Paper withBorder radius="md" p="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<ReviewRow
label={t("exam.form.certification")}
value={
certOptions.find((c) => c.value === certificationId)?.label
}
/>
<ReviewRow label={t("exam.form.titleEn")} value={titleEn} />
<ReviewRow label={t("exam.form.titleAm")} value={titleAm} />
<ReviewRow label={t("exam.form.examDate")} value={date} />
<ReviewRow label={t("exam.form.venue")} value={venue} />
<ReviewRow
label={t("exam.detail.timeAllowed")}
value={t("exam.form.durationSummary", {
days,
hours,
minutes,
})}
/>
<ReviewRow
label={t("exam.columns.type")}
value={type ? t(`exam.type.${type}`) : undefined}
/>
<ReviewRow
label={t("exam.columns.form")}
value={form ? t(`exam.formType.${form}`) : undefined}
/>
<ReviewRow
label={t("exam.detail.administration")}
value={adminMethod}
/>
<ReviewRow
label={t("exam.detail.evaluation")}
value={evalMethod}
/>
<ReviewRow
label={t("exam.detail.selection")}
value={selMethod}
/>
<ReviewRow
label={t("exam.form.cuttingPoint")}
value={String(cuttingPoint)}
/>
</SimpleGrid>
</Paper>
</Stack>
</Stepper.Step>
</Stepper>
<ModalFooter mt="md">
<Button variant="default" onClick={onCancel} size="sm">
{t("exam.cancel")}
</Button>
{activeTab === "basic" ? (
/* Not type="submit": Basic Info is not the last step, so the
primary action advances rather than saves. */
{step > 0 && (
<Button variant="default" size="sm" onClick={goBack}>
{t("exam.form.back")}
</Button>
)}
{step < LAST_STEP ? (
/* Deliberately not type="submit": only the last step submits, so
there is no path from an earlier one into the API. */
<Button size="sm" onClick={goNext}>
{t("exam.form.next")}
</Button>
) : (
<>
<Button
variant="default"
size="sm"
onClick={() => setActiveTab("basic")}
>
{t("exam.form.back")}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editing ? t("exam.update") : t("exam.create")}
</Button>
</>
<Button type="submit" size="sm" loading={isSubmitting}>
{editing ? t("exam.update") : t("exam.create")}
</Button>
)}
</ModalFooter>
</form>

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import {
LAST_STEP,
stepOfError,
validateAll,
validateBasic,
validateSettings,
validateStep,
type ExamFormValues,
} from './validation';
const complete: ExamFormValues = {
certificationId: 'cert-1',
titleEn: 'Master Mariner',
titleAm: 'ካፒቴን',
directionEn: '',
directionAm: '',
date: '2026-09-10',
venue: 'Addis Ababa',
type: 'WRITTEN',
form: 'CHOICE',
adminMethod: 'ONLINE',
evalMethod: 'SUM',
cuttingPoint: 50,
};
const basicOnly: ExamFormValues = {
...complete,
type: null,
form: null,
adminMethod: null,
evalMethod: null,
cuttingPoint: 0,
};
/**
* Clicking "Create Exam" from Basic Info used to report the Settings fields as
* missing — a correct error the user could not act on, since that step had not
* been shown yet. Each step now answers only for its own fields.
*/
describe('exam form step validation', () => {
it('keeps an incomplete Basic Info on its own step', () => {
const error = validateStep(0, { ...basicOnly, venue: '' });
expect(error).toBe('exam.form.fillRequiredBasic');
expect(stepOfError(error!)).toBe(0);
});
it('does not fault Basic Info for Settings fields that are still empty', () => {
expect(validateStep(0, basicOnly)).toBeNull();
});
it('wants a direction in both languages or in neither', () => {
expect(validateBasic({ ...complete, directionEn: 'Answer all' })).toBe(
'exam.form.directionBothLanguages',
);
expect(validateBasic({ ...complete, directionEn: 'Answer all', directionAm: 'ሁሉንም' })).toBeNull();
});
it('keeps an incomplete Settings on its own step', () => {
const error = validateStep(1, basicOnly);
expect(error).toBe('exam.form.fillRequiredSettings');
expect(stepOfError(error!)).toBe(1);
});
it('lets a complete Settings through to the review step', () => {
expect(validateStep(1, complete)).toBeNull();
});
it('asks nothing of the review step itself — it has no fields', () => {
expect(validateStep(LAST_STEP, basicOnly)).toBeNull();
});
it('checks the whole payload on submit, not just the last step', () => {
// Reachable: the stepper header can be clicked back to a visited step, so
// arriving at Review is no proof everything is still filled in.
expect(validateAll(basicOnly)).toBe('exam.form.fillRequiredSettings');
expect(validateAll({ ...complete, titleAm: '' })).toBe('exam.form.fillRequiredBasic');
expect(validateAll(complete)).toBeNull();
});
it('treats a zero cutting point as unset — a paper nobody can fail', () => {
expect(validateSettings({ ...complete, cuttingPoint: 0 })).toBe(
'exam.form.fillRequiredSettings',
);
});
});

View File

@@ -0,0 +1,65 @@
/**
* Which step owns which required fields.
*
* Extracted from the form so the rule "Next checks only the step in front of
* the user" is testable without rendering a modal — and so the final submit
* and the per-step check can never drift apart, since both read this.
*/
export interface ExamFormValues {
certificationId: string | null;
titleEn: string;
titleAm: string;
directionEn: string;
directionAm: string;
date: string;
venue: string;
type: string | null;
form: string | null;
adminMethod: string | null;
evalMethod: string | null;
cuttingPoint: number;
}
/** Basic Info, Settings, Review — only the last one submits. */
export const LAST_STEP = 2;
export function validateBasic(v: ExamFormValues): string | null {
if (!v.certificationId || !v.titleEn || !v.titleAm || !v.date || !v.venue) {
return 'exam.form.fillRequiredBasic';
}
// Either both languages or neither: a direction in one language only reads
// as missing to half the candidates.
if ((v.directionEn || v.directionAm) && !(v.directionEn && v.directionAm)) {
return 'exam.form.directionBothLanguages';
}
return null;
}
export function validateSettings(v: ExamFormValues): string | null {
return !v.type || !v.form || !v.adminMethod || !v.evalMethod || !v.cuttingPoint
? 'exam.form.fillRequiredSettings'
: null;
}
/** The step in front of the user, and nothing else. Review has no fields. */
export function validateStep(step: number, v: ExamFormValues): string | null {
if (step === 0) return validateBasic(v);
if (step === 1) return validateSettings(v);
return null;
}
/**
* The whole payload, as the final check before submitting.
*
* Not redundant with the per-step checks: the stepper header can be clicked
* back to a visited step, so arriving at Review is no proof the user walked
* here in order with everything still filled in.
*/
export function validateAll(v: ExamFormValues): string | null {
return validateBasic(v) ?? validateSettings(v);
}
/** Where a failure has to land for the user to be able to fix it. */
export function stepOfError(error: string): number {
return error === 'exam.form.fillRequiredSettings' ? 1 : 0;
}

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

@@ -1,110 +0,0 @@
import { useState } from 'react';
import { Alert, Button, Modal, Select, Stack, Text } from '@mantine/core';
import { IconCalendarEvent } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui';
import { useGetEligibleExamsQuery } from '@ema-platform/api';
interface Props {
opened: boolean;
applicationId: string;
applicantName: string;
loading: boolean;
onClose: () => void;
onConfirm: (payload: { examId: string; examDate?: string }) => void;
}
/**
* Places a candidate who has paid the examination fee into an existing sitting.
*
* Sessions are picked from the exam calendar rather than typed, because the
* candidate joins a scheduled sitting — this is an assignment, not the creation
* of a per-candidate appointment. Scoped to sittings whose certification
* matches this application's rank, so a Chief Mate candidate cannot be seated
* into an OOW Deck sitting by accident.
*
* Scheduling makes the sitting available; it does not register the candidate.
* That is their own act, from the portal's Register button.
*/
export function ScheduleExamModal({
opened,
applicationId,
applicantName,
loading,
onClose,
onConfirm,
}: Props) {
const { t } = useTranslation();
const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
const [examId, setExamId] = useState<string | null>(null);
const options = (exams ?? []).map((exam) => ({
value: exam.id,
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
.filter(Boolean)
.join(' — '),
}));
const selected = exams?.find((exam) => exam.id === examId);
function confirm() {
if (!examId) return;
onConfirm({
examId,
examDate: selected?.date ? String(selected.date) : undefined,
});
}
return (
<Modal
opened={opened}
onClose={onClose}
title={t('review.actions.scheduleExam', 'Schedule exam')}
>
<Stack>
<Text size="sm" c="dimmed">
{t('review.scheduleExam.intro', {
defaultValue:
'Make a sitting available to {{applicant}}. They register for it themselves from the portal.',
applicant: applicantName,
})}
</Text>
{!isLoading && options.length === 0 ? (
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
{t(
'review.scheduleExam.noSessions',
'No exam sessions for this rank exist yet. Create one in the Exams area first.',
)}
</Alert>
) : (
<Select
label={t('review.scheduleExam.session', 'Exam session')}
placeholder={t('review.scheduleExam.pick', 'Choose a sitting')}
data={options}
value={examId}
onChange={setExamId}
disabled={isLoading}
searchable
withAsterisk
/>
)}
<Text size="xs" c="dimmed">
{t(
'review.scheduleExam.admissionHint',
'Scheduling does not seat the candidate. They must register for the sitting from the portal, and the admission number is issued then.',
)}
</Text>
<ModalFooter>
<Button variant="default" onClick={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button loading={loading} disabled={!examId} onClick={confirm}>
{t('review.scheduleExam.confirm', 'Schedule')}
</Button>
</ModalFooter>
</Stack>
</Modal>
);
}

View File

@@ -33,8 +33,6 @@ export type ActionId =
| 'final-approve'
| 'request-adjustment'
| 'reject'
| 'schedule-exam'
| 'record-exam-outcome'
| 'confirm-payment'
| 'schedule-issuance'
| 'issue-certificate'
@@ -276,31 +274,12 @@ export const ACTIONS: ActionDefinition[] = [
requiresReason: true,
irreversible: true,
},
{
id: 'schedule-exam',
tier: 'primary',
labelKey: 'review.actions.scheduleExam',
// Only after the examination fee clears — scheduling an unpaid candidate
// is what the EXAM_PAID gate exists to prevent.
from: ['EXAM_PAID'],
// Matches the controller's guard on `:id/exam-scheduled`
// (`LICENSE_PERMISSIONS.MANAGE_EXAMS`) — the previous string didn't
// correspond to any real permission constant, so this button could never
// actually be granted to anyone.
permissions: ['can:manage:exams'],
emphasis: 'filled',
color: 'cyan',
},
{
id: 'record-exam-outcome',
tier: 'primary',
labelKey: 'review.actions.recordExamOutcome',
// Only once the candidate has actually sat the exam.
from: ['EXAM_SCHEDULED'],
permissions: ['can:publish:exam-result'],
emphasis: 'filled',
color: 'cyan',
},
// No `schedule-exam` or `record-exam-outcome`. CoC sittings are published as
// master schedules and the candidate registers for one themselves, so an
// officer action that seated a named applicant registered people who had
// never asked. An outcome is what the exam engine computed and a supervisor
// approved; a queue action taking pass/fail and a score let the back office
// manufacture one with no paper behind it. Both endpoints are gone too.
{
id: 'confirm-payment',
tier: 'primary',
@@ -391,7 +370,7 @@ export interface ResolveContext {
/**
* Action ids that are workflow events, so `availableEvents` decides them.
*
* The rest (`schedule-inspection`, `schedule-exam`, the secondary tools) are
* The rest (`schedule-inspection`, the secondary tools) are
* screens and side effects rather than transitions, and the server has no
* opinion on them — those keep using their own `from` list.
*/

View File

@@ -55,8 +55,6 @@ import {
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useScheduleExamMutation,
useRecordExamOutcomeMutation,
useEscalateApplicationMutation,
useFinalApproveMutation,
useGetApplicationForReviewQuery,
@@ -97,8 +95,8 @@ 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 { ScheduleExamModal } from "../../components/ScheduleExamModal";
import { computeSla } from "../../sla";
import { reviewStaffColumns } from "./columns";
import {
@@ -164,6 +162,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();
@@ -230,9 +237,6 @@ export function LicenseReviewPage() {
null,
);
const [certificatePreview, setCertificatePreview] = useState<string | null>(null);
const [scheduleExam, { isLoading: schedulingExam }] =
useScheduleExamMutation();
const [recordExamOutcome] = useRecordExamOutcomeMutation();
const [holdApplication] = useHoldApplicationMutation();
const [resumeApplication] = useResumeApplicationMutation();
const [escalateApplication] = useEscalateApplicationMutation();
@@ -273,9 +277,6 @@ export function LicenseReviewPage() {
"MORNING",
);
const [resultOpen, setResultOpen] = useState(false);
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
const [examScore, setExamScore] = useState<number | undefined>();
const [findings, setFindings] = useState("");
const [findingsUploadBusy, setFindingsUploadBusy] = useState(false);
const [findingsPreview, setFindingsPreview] = useState<
@@ -600,16 +601,6 @@ export function LicenseReviewPage() {
case "record-inspection":
setResultOpen(true);
return;
// Needs a session picked before anything is sent, so it opens its own
// modal rather than going through the generic confirm step.
case "schedule-exam":
setScheduleExamOpen(true);
return;
// Pass/fail plus an optional score, same reasoning as schedule-exam:
// needs its own inputs before anything is sent.
case "record-exam-outcome":
setExamOutcomeOpen(true);
return;
case "copy-link":
navigator.clipboard.writeText(window.location.href);
notifications.show({
@@ -1064,6 +1055,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")}
@@ -1393,101 +1389,10 @@ export function LicenseReviewPage() {
onConfirm={submitDecision}
/>
<ScheduleExamModal
opened={scheduleExamOpen}
applicationId={id}
applicantName={
app.companyName ||
applicantFullName ||
t("review.theApplicant", "the applicant")
}
loading={schedulingExam}
onClose={() => setScheduleExamOpen(false)}
onConfirm={async (payload) => {
try {
await scheduleExam({ id, ...payload }).unwrap();
notifications.show({
color: "teal",
title: t("review.done.scheduleExam", "Exam scheduled"),
message: "",
});
setScheduleExamOpen(false);
} catch (err) {
notifications.show({
color: "red",
title: t("review.actionFailed", "Action failed"),
message: extractErrorMessage(err),
});
}
}}
/>
<Modal
opened={examOutcomeOpen}
onClose={() => setExamOutcomeOpen(false)}
title={t("review.actions.recordExamOutcome", "Record exam outcome")}
>
<Stack>
<Text size="sm" c="dimmed">
{t(
"review.examOutcome.intro",
"Record the published result. A pass makes the certificate fee due; a fail leaves the application open for a retake.",
)}
</Text>
<NumberInput
label={t("review.examOutcome.score", "Score (optional)")}
value={examScore}
onChange={(v) => setExamScore(typeof v === "number" ? v : undefined)}
min={0}
/>
<ModalFooter grow>
<ActionIcon
variant="light"
color="teal"
size="lg"
aria-label={t("review.passed", "Passed")}
onClick={() =>
run(
async () => {
await recordExamOutcome({
id,
passed: true,
score: examScore,
}).unwrap();
setExamOutcomeOpen(false);
setExamScore(undefined);
},
t("review.done.examPassed", "Exam result recorded — passed"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="lg"
aria-label={t("review.failed", "Failed")}
onClick={() =>
run(
async () => {
await recordExamOutcome({
id,
passed: false,
score: examScore,
}).unwrap();
setExamOutcomeOpen(false);
setExamScore(undefined);
},
t("review.done.examFailed", "Exam result recorded — not passed"),
)
}
>
<IconX size={18} />
</ActionIcon>
</ModalFooter>
</Stack>
</Modal>
{/* No exam scheduling or outcome modal. Candidates register themselves
for a published sitting, and the outcome is whatever the exam engine
computed and a supervisor approved — the queue reads results, it does
not write them. */}
<Modal
opened={inspectionOpen}

View File

@@ -278,6 +278,9 @@ export const am: Translations = {
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
next: "ቀጣይ",
back: "ተመለስ",
review: "ግምገማ",
reviewHint: "ከዚህ በታች ያለውን ዝርዝር ያረጋግጡ፣ ከዚያ ፈተናውን ይፍጠሩ። ለማስተካከል ተመለስ ይጠቀሙ።",
durationSummary: "{{days}}ቀን {{hours}}ሰ {{minutes}}ደ",
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
status: "ሁኔታ",
statusPlaceholder: "የፈተና ሁኔታ",
@@ -721,6 +724,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: "ተመርምሯል",
@@ -1073,8 +1097,6 @@ export const am: Translations = {
finalApprove: "አጽድቅ እና ስጥ",
requestAdjustment: "ማስተካከያ ጠይቅ",
reject: "አትቀበል",
scheduleExam: "የፈተና ቀጠሮ ስጥ",
recordExamOutcome: "የፈተና ውጤት መዝግብ",
confirmPayment: "ክፍያ አረጋግጥ",
scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ",
issueCertificate: "ሰርተፍኬት ስጥ",
@@ -1127,7 +1149,6 @@ export const am: Translations = {
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
"assign-reviewer": "ማመልከቻ {{number}} ለተመረጠው ሹም ሰጥቶ ግምገማውን ያስጀምራል።",
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
},
notifications: {
fallback: "ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።",

View File

@@ -278,6 +278,9 @@ export const en = {
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
next: 'Next',
back: 'Back',
review: 'Review',
reviewHint: 'Check the details below, then create the exam. Use Back to change anything.',
durationSummary: '{{days}}d {{hours}}h {{minutes}}m',
status: 'Status',
statusPlaceholder: 'Exam status',
pending: 'Pending',
@@ -722,6 +725,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',
@@ -1083,8 +1107,6 @@ export const en = {
finalApprove: 'Approve & issue',
requestAdjustment: 'Request adjustment',
reject: 'Reject',
scheduleExam: 'Schedule exam',
recordExamOutcome: 'Record exam outcome',
confirmPayment: 'Confirm payment',
scheduleIssuance: 'Schedule pickup',
issueCertificate: 'Issue certificate',
@@ -1137,7 +1159,6 @@ export const en = {
'assign-reviewer':
'Hands application {{number}} to the chosen officer and starts the review.',
'confirm-payment': 'Confirms settlement for application {{number}}.',
'schedule-exam': 'Assigns {{applicant}} to an exam session for application {{number}}.',
},
notifications: {
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',

View File

@@ -44,6 +44,8 @@ import {
} from '@ema-platform/api';
import { ConfirmModal, PdfPreviewModal } from '@ema-platform/ui';
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
import { examStageFor, registrationForApplication } from '../../licensing/exam-stage';
import type { MyRegistration } from '../../exams/pages/ExamsPage';
// ---------------------------------------------------------------------------
// Mock data
@@ -106,6 +108,21 @@ const STATUS_COLOR: Record<string, string> = {
SUPERSEDED: 'gray',
};
/**
* What the examination leg reads as once the registration and attempt are
* taken into account — see `examStageFor`. Plain strings rather than i18n
* keys because this page states its statuses the same way.
*/
const EXAM_STAGE_LABELS: Record<string, string> = {
ELIGIBLE_TO_REGISTER: 'Eligible — Register for a Sitting',
REGISTERED: 'Exam Scheduled',
ATTENDANCE_CONFIRMED: 'Exam Attendance Confirmed',
SITTING: 'Exam In Progress',
UNDER_EVALUATION: 'Exam Completed — Under Evaluation',
PASSED: 'Passed — Certificate Fee Due',
FAILED: 'Not Passed',
};
/** Turns `EXAM_PAYMENT_PENDING` into something a person reads. */
function humanStatus(status: string): string {
return status
@@ -200,6 +217,25 @@ export function CertificatesPage() {
const certificates = data?.certificates ?? [];
const applications = data?.applications ?? [];
// Registering, attendance and the sitting itself are recorded against the
// registration, not the application — reading only the application status is
// how this page could still say the candidate was waiting for an exam they
// had already passed. Polled, because attendance is recorded while the
// candidate is watching this screen.
const { data: examRegistrations } = useApiQuery<MyRegistration[]>(
{ url: '/exams/registrations/mine', method: 'GET' },
{ pollingInterval: 30_000, refetchOnFocus: true },
);
/** Exam-leg applications read as the stage the records add up to. */
const applicationStatusLabel = (app: { id: string; status: string }) => {
const stage = examStageFor(
app,
registrationForApplication(app.id, examRegistrations),
);
return stage ? EXAM_STAGE_LABELS[stage] : humanStatus(app.status);
};
// eligibleForCoc is computed server-side (seafarer registration approved,
// plus a verified sea service record and a verified medical certificate)
// so the button and the API's own eligibility check can never disagree.
@@ -359,7 +395,7 @@ export function CertificatesPage() {
<Table.Td><Text fz="xs">{formatDate(app.submitted)}</Text></Table.Td>
<Table.Td>
<Text fz="xs" c="dimmed" maw={200} lh={1.3}>
{humanStatus(app.status)}
{applicationStatusLabel(app)}
</Text>
</Table.Td>
<Table.Td>
@@ -368,7 +404,7 @@ export function CertificatesPage() {
variant="light"
size="sm"
>
{humanStatus(app.status)}
{applicationStatusLabel(app)}
</Badge>
</Table.Td>
<Table.Td>

View File

@@ -53,6 +53,8 @@ export interface MyRegistration {
kind: 'NEW' | 'RETAKE';
attemptNumber: number;
attendanceStatus: AttendanceStatus;
/** The certificate application this sitting answers, when there is one. */
applicationId: string | null;
exam?: OpenExam;
/** The candidate's online sitting, when one has been started. */
attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;

View File

@@ -9,6 +9,8 @@ interface Props {
paying: boolean;
onRetakeExam: (app: LicenseApplication) => void;
onPay: (app: LicenseApplication) => void;
/** Opens the exams screen, where sittings are listed and registered for. */
onRegisterForExam: () => void;
}
/**
@@ -29,6 +31,7 @@ export function ExamStageActions({
paying,
onRetakeExam,
onPay,
onRegisterForExam,
}: Props) {
// The eligibility fee is invoiced the moment the application is submitted —
// there is no separate "request" step, so this is a pay button, exactly
@@ -112,14 +115,29 @@ export function ExamStageActions({
);
}
// Paid and scheduled are both waiting states — nothing for the candidate to
// do, so say so rather than offering a button that does nothing.
if (app.status === 'EXAM_PAID' || app.status === 'EXAM_SCHEDULED') {
// Not a waiting state any more. Nobody seats the candidate: the exam fee
// having cleared is precisely what makes them eligible to pick a published
// sitting, so this is the one thing left for them to do.
if (app.status === 'EXAM_PAID') {
return (
<Button size="xs" variant="subtle" disabled>
{app.status === 'EXAM_PAID'
? t('applications.actions.awaitingDate', 'Awaiting exam date')
: t('applications.actions.examScheduled', 'Exam scheduled')}
<Button
size="xs"
variant="filled"
color="cyan"
onClick={onRegisterForExam}
>
{t('applications.actions.registerForExam', 'Register for a sitting')}
</Button>
);
}
// Registered. What happens next is the invigilator's, then the examiner's —
// the exams screen is where the candidate watches it, and where Take Exam
// appears once attendance is confirmed.
if (app.status === 'EXAM_SCHEDULED') {
return (
<Button size="xs" variant="light" onClick={onRegisterForExam}>
{t('applications.actions.viewSitting', 'View my sitting')}
</Button>
);
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { examStageFor, registrationForApplication } from './exam-stage';
import type { MyRegistration } from '../exams/pages/ExamsPage';
/* eslint-disable @typescript-eslint/no-explicit-any */
const registration = (over: Partial<MyRegistration> = {}): MyRegistration =>
({
id: 'registration-1',
admissionNumber: 'ADM-2026-000001',
createdAt: '2026-08-01',
kind: 'NEW',
attemptNumber: 1,
attendanceStatus: 'REGISTERED',
applicationId: 'app-1',
exam: { id: 'exam-1', date: '2026-09-10' } as any,
attempt: null,
...over,
}) as MyRegistration;
/**
* The portal kept saying "Awaiting Exam Date" after the back office had
* evaluated the paper, because it read only the application status —
* registering, attendance and the sitting all happen on the registration.
*/
describe('examStageFor', () => {
it('leaves non-exam applications to the ordinary status label', () => {
expect(examStageFor({ status: 'UNDER_REVIEW' })).toBeNull();
expect(examStageFor({ status: 'CERTIFICATE_ISSUED' })).toBeNull();
});
it('reads a paid application as eligible to register, not as waiting', () => {
expect(examStageFor({ status: 'EXAM_PAID' })).toBe('ELIGIBLE_TO_REGISTER');
});
it('reports a registration whose attendance has not been taken', () => {
expect(
examStageFor({ status: 'EXAM_SCHEDULED' }, registration()),
).toBe('REGISTERED');
});
it.each(['PRESENT', 'LATE'] as const)(
'reports attendance confirmed once an invigilator marks %s',
(attendanceStatus) => {
expect(
examStageFor({ status: 'EXAM_SCHEDULED' }, registration({ attendanceStatus })),
).toBe('ATTENDANCE_CONFIRMED');
},
);
it('does not treat a candidate who is not sitting as confirmed', () => {
expect(
examStageFor({ status: 'EXAM_SCHEDULED' }, registration({ attendanceStatus: 'ABSENT' })),
).toBe('REGISTERED');
});
it('follows the attempt once the candidate starts', () => {
expect(
examStageFor(
{ status: 'EXAM_SCHEDULED' },
registration({ attendanceStatus: 'PRESENT', attempt: { status: 'IN_PROGRESS' } }),
),
).toBe('SITTING');
});
it.each(['SUBMITTED', 'EXPIRED'] as const)(
'reports a %s attempt as awaiting evaluation',
(status) => {
expect(
examStageFor(
{ status: 'EXAM_SCHEDULED' },
registration({ attendanceStatus: 'PRESENT', attempt: { status } }),
),
).toBe('UNDER_EVALUATION');
},
);
it('reports the published outcome, whatever the registration still says', () => {
expect(examStageFor({ status: 'EXAM_PASSED' }, registration())).toBe('PASSED');
expect(examStageFor({ status: 'EXAM_FAILED' }, registration())).toBe('FAILED');
});
it('falls back to registered when the registration has not loaded yet', () => {
expect(examStageFor({ status: 'EXAM_SCHEDULED' })).toBe('REGISTERED');
});
});
describe('registrationForApplication', () => {
it('matches on the application the sitting answers', () => {
const mine = [registration({ applicationId: 'app-2', id: 'r-2' }), registration()];
expect(registrationForApplication('app-1', mine)?.id).toBe('registration-1');
expect(registrationForApplication('app-3', mine)).toBeUndefined();
expect(registrationForApplication('app-1', undefined)).toBeUndefined();
});
it('ignores registrations made outside the certificate course', () => {
const mine = [registration({ applicationId: null })];
expect(registrationForApplication('app-1', mine)).toBeUndefined();
});
});

View File

@@ -0,0 +1,68 @@
import type { LicenseApplication } from '@ema-platform/api';
import type { MyRegistration } from '../exams/pages/ExamsPage';
/**
* Where a candidate actually stands in the examination leg.
*
* The application's own status cannot answer this on its own. It knows the
* fee was paid and, later, whether the candidate passed — but registering for
* a sitting, being marked present and sitting the paper all happen on the
* registration and the attempt, and the portal was reading none of them. That
* is why an application could still read "Awaiting Exam Date" after the back
* office had already evaluated the paper.
*
* Every value below is read off server state (application status, attendance
* ruling, attempt status). Nothing here decides anything the backend has not
* already recorded — this only names what the records add up to.
*/
export type ExamStage =
| 'ELIGIBLE_TO_REGISTER'
| 'REGISTERED'
| 'ATTENDANCE_CONFIRMED'
| 'SITTING'
| 'UNDER_EVALUATION'
| 'PASSED'
| 'FAILED';
/** Attendance rulings that mean an invigilator confirmed the candidate. */
const CONFIRMED = ['PRESENT', 'LATE'];
/**
* Null for anything outside the examination leg, so callers can fall back to
* the ordinary status label without a second condition.
*/
export function examStageFor(
// Widened to the raw string rather than LicenseStatus: two screens read
// applications from different endpoints, and only one of them types the
// status as the enum.
app: { status: LicenseApplication['status'] | string },
registration?: MyRegistration,
): ExamStage | null {
if (app.status === 'EXAM_PASSED') return 'PASSED';
if (app.status === 'EXAM_FAILED') return 'FAILED';
// EXAM_PAID is "prerequisites done, sitting not yet chosen": the fee has
// cleared and nothing is left but for the candidate to pick a session.
if (app.status === 'EXAM_PAID') return 'ELIGIBLE_TO_REGISTER';
if (app.status !== 'EXAM_SCHEDULED') return null;
// Registered, but the row has not caught up yet — the application only
// reaches EXAM_SCHEDULED by the candidate registering, so this is a
// fetch-ordering gap rather than a real state.
if (!registration) return 'REGISTERED';
const attempt = registration.attempt?.status;
if (attempt === 'SUBMITTED' || attempt === 'EXPIRED') return 'UNDER_EVALUATION';
if (attempt === 'IN_PROGRESS') return 'SITTING';
if (CONFIRMED.includes(registration.attendanceStatus)) return 'ATTENDANCE_CONFIRMED';
return 'REGISTERED';
}
/** The registration that answers one application, if the candidate has one. */
export function registrationForApplication(
applicationId: string,
registrations: MyRegistration[] | undefined,
): MyRegistration | undefined {
return registrations?.find((r) => r.applicationId === applicationId);
}

View File

@@ -31,6 +31,7 @@ export function applicationActionsColumn(
onCertificate: (app: LicenseApplication) => void;
onPay: (app: LicenseApplication) => void;
onRetakeExam: (app: LicenseApplication) => void;
onRegisterForExam: () => void;
onOpen: (app: LicenseApplication) => void;
onDiscard: (app: LicenseApplication) => void;
},
@@ -51,6 +52,7 @@ export function applicationActionsColumn(
paying={deps.isPaying}
onRetakeExam={deps.onRetakeExam}
onPay={deps.onPay}
onRegisterForExam={deps.onRegisterForExam}
/>
{/* Every fee stop is bypassable — an examined certificate charges
three separate fees (eligibility, exam, certificate) and is

View File

@@ -29,6 +29,9 @@ export function applicationColumns(
language: string;
showDate: (date: string) => string;
statusLabel: (status: LicenseStatus) => string;
/** Status as the applicant should read it — the exam leg resolves to the
* stage the registration and attempt actually record. */
applicationStatusLabel: (app: LicenseApplication) => string;
},
): AdvancedColumn<LicenseApplication>[] {
return [
@@ -60,7 +63,7 @@ export function applicationColumns(
header: t('common.status'),
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{deps.statusLabel(row.original.status)}
{deps.applicationStatusLabel(row.original)}
</Badge>
),
},

View File

@@ -47,8 +47,10 @@ import {
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetPaymentCapabilitiesQuery,
useApiQuery,
useRetakeExamMutation,
type ApplicationKind,
type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api';
import {
@@ -56,6 +58,8 @@ import {
RequirePermission,
usePermissions,
} from '@ema-platform/auth';
import { examStageFor, registrationForApplication } from '../../exam-stage';
import type { MyRegistration } from '../../../exams/pages/ExamsPage';
import { applicationColumns } from './columns';
import { applicationActionsColumn } from './actions';
import classes from '../MyApplicationsPage.module.css';
@@ -97,6 +101,15 @@ export function MyApplicationsPage() {
const { t, i18n } = useTranslation();
const showDate = useDateDisplayer();
const { data, isFetching, refetch } = useGetMyApplicationsQuery();
// The examination leg lives on the registration and the attempt, not on the
// application: registering, being marked present and sitting the paper all
// happen there. Polled rather than fetched once, because attendance is
// recorded by an invigilator while the candidate is sitting in front of this
// screen — a status that only updates on reload is the defect itself.
const { data: examRegistrations } = useApiQuery<MyRegistration[]>(
{ url: '/exams/registrations/mine', method: 'GET' },
{ pollingInterval: 30_000, refetchOnFocus: true },
);
const { pay, isPaying } = useApplicationPayment();
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
@@ -297,10 +310,32 @@ export function MyApplicationsPage() {
const statusLabel = (status: LicenseStatus) =>
t(`applications.status.${status}`, { defaultValue: status });
/**
* The exam leg gets the stage the records actually add up to; everything
* else keeps the plain status label.
*/
const applicationStatusLabel = (app: LicenseApplication) => {
const stage = examStageFor(
app,
registrationForApplication(app.id, examRegistrations),
);
if (!stage) return statusLabel(app.status);
const registration = registrationForApplication(app.id, examRegistrations);
return t(`applications.examStage.${stage}`, {
defaultValue: statusLabel(app.status),
date: registration?.exam?.date ? showDate(registration.exam.date) : '',
});
};
const allStatuses = Object.keys(STATUS_COLORS) as LicenseStatus[];
const columns = [
...applicationColumns(t, { language: i18n.language, showDate, statusLabel }),
...applicationColumns(t, {
language: i18n.language,
showDate,
statusLabel,
applicationStatusLabel,
}),
applicationActionsColumn(t, {
can,
bypassEnabled: capabilities?.bypassEnabled ?? false,
@@ -311,6 +346,7 @@ export function MyApplicationsPage() {
onCertificate: (app) => openCertificateForApplication(app.id),
onPay: (app) => pay(app.id),
onRetakeExam: (app) => retakeExamFee(app.id),
onRegisterForExam: () => navigate('/exams'),
onOpen: (app) =>
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
onDiscard: (app) =>

View File

@@ -243,6 +243,15 @@ export const am: Translations = {
licences: {
empty: 'እስካሁን ምንም ፍቃድ አልተሰጥዎትም። ማመልከቻ ከተፈቀደና ከተከፈለ በኋላ እዚህ ይታያል።',
},
examStage: {
ELIGIBLE_TO_REGISTER: "ብቁ ነዎት — ለፈተና ይመዝገቡ",
REGISTERED: "የፈተና ቀን፦ {{date}}",
ATTENDANCE_CONFIRMED: "መገኘት ተረጋግጧል",
SITTING: "ፈተና በመካሄድ ላይ",
UNDER_EVALUATION: "ፈተና ተጠናቋል — በግምገማ ላይ",
PASSED: "አልፈዋል",
FAILED: "አላለፉም",
},
status: {
DRAFT: 'ረቂቅ',
SUBMITTED: 'ገብቷል',

View File

@@ -244,6 +244,19 @@ export const en = {
licences: {
empty: 'No licence has been issued to you yet. One appears here once an application is approved and paid.',
},
/**
* The examination leg, named from what the records add up to rather than
* from the application status alone — see exam-stage.ts.
*/
examStage: {
ELIGIBLE_TO_REGISTER: 'Eligible — register for a sitting',
REGISTERED: 'Exam scheduled: {{date}}',
ATTENDANCE_CONFIRMED: 'Attendance confirmed',
SITTING: 'Exam in progress',
UNDER_EVALUATION: 'Exam completed — under evaluation',
PASSED: 'Passed',
FAILED: 'Not passed',
},
status: {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',

View File

@@ -1,5 +1,6 @@
import { baseApi } from '../../base-api';
import type {
ExamStateView,
AppNotification,
ApplicationDetail,
ApplicationKind,
@@ -25,7 +26,6 @@ import type {
CompletionEffect,
DocumentDecision,
DocumentReview,
EligibleExam,
ExportResult,
LicenseTemplate,
Paginated,
@@ -831,44 +831,22 @@ export const licensingApi = baseApi
}),
/**
* Exam sittings valid for this application's rank — what the
* schedule-exam picker offers, instead of every exam in the system.
* 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.
*/
getEligibleExams: builder.query<EligibleExam[], string>({
query: (id) => ({ url: `/license-application-review/${id}/eligible-exams` }),
getExamStateForApplication: builder.query<ExamStateView, string>({
query: (id) => ({ url: `/exams/applications/${id}/state` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
}),
/** Places a candidate who has paid the examination fee into a sitting. */
scheduleExam: builder.mutation<
LicenseApplication,
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
>({
query: ({ id, examDate: _examDate, ...body }) => ({
// Matches the controller's `:id/exam-scheduled` route — `examDate`
// is UI-only context for the confirmation toast, not part of
// `MarkExamScheduledDto`, so it never goes on the wire.
url: `/license-application-review/${id}/exam-scheduled`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
/** Records a published examination result (pass or fail). */
recordExamOutcome: builder.mutation<
LicenseApplication,
{ id: string; passed: boolean; score?: number }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/exam-outcome`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
// 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
// is what the exam engine computed and a supervisor approved — neither
// is the COC queue's to write.
/**
* A failed candidate asks for another sitting. Re-opens the examination
@@ -1396,6 +1374,7 @@ export const {
useUpdateLicenseValidityMutation,
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
useGetExamStateForApplicationQuery,
useDiscardApplicationMutation,
useGetMyApplicationsQuery,
useGetApplicationQuery,
@@ -1455,9 +1434,6 @@ export const {
useApproveDocumentsMutation,
useFinalApproveMutation,
useRejectApplicationMutation,
useGetEligibleExamsQuery,
useScheduleExamMutation,
useRecordExamOutcomeMutation,
useRetakeExamMutation,
useConfirmPaymentMutation,
useScheduleIssuanceMutation,

View File

@@ -86,7 +86,9 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
ELIGIBILITY_PAYMENT_PENDING: 'Eligibility Fee Due',
ELIGIBILITY_PAID: 'Eligibility Under Review',
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
EXAM_PAID: 'Awaiting Exam Date',
// Nobody assigns a date any more — the fee clearing is what makes the
// candidate eligible to register for a published sitting themselves.
EXAM_PAID: 'Eligible to Register',
EXAM_SCHEDULED: 'Exam Scheduled',
EXAM_PASSED: 'Exam Passed',
EXAM_FAILED: 'Exam Not Passed',

View File

@@ -911,12 +911,39 @@ export interface PersonalDocumentFilter {
locale?: 'en' | 'am';
}
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
export interface EligibleExam {
id: string;
title: { en: string; am: string };
date: string;
venue: string;
status: string;
certification?: { id: string; name: { en: string; am: string }; rankKey: string | null };
// No `EligibleExam`: it typed the schedule-exam picker's options, and
// picking a sitting for a named applicant is not something the back office
// does any more — candidates register for a published sitting themselves.
/**
* 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;
}