mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-01 18:13:28 +00:00
fix(exam): step the exam form, derive portal status, drop queue overrides
Four screens were telling the applicant things that were not true, and two back-office actions were writing state nobody should be able to write by hand. The exam form is a stepper. Basic Info and Settings each answer only for their own fields, and a third step reviews the whole thing before anything is sent — "Create Exam" exists on that step alone, so there is no path from an earlier one into the API. Clicking it 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. The rules move to ./validation, which is what the per-step check and the final whole-payload check both read, so the two cannot drift apart. Back keeps everything entered — state lives in the component. The portal derives the examination stage from the records rather than from the application status alone. Registering, attendance and the sitting itself all happen on the registration and the attempt, and the portal read none of them — which is how an application still said "Awaiting Exam Date" after the back office had already evaluated the paper. examStageFor() names what the records add up to: eligible to register, registered, attendance confirmed, sitting, under evaluation, passed, failed. Both the applications list and the certificates page use it, and both poll the registrations they read it from, because attendance is recorded by an invigilator while the candidate is watching the screen. EXAM_PAID is no longer a waiting state. Nobody assigns a date: the fee having cleared is exactly what makes the candidate eligible to pick a published sitting, so that row now offers Register rather than a disabled button. Schedule Exam and Record Exam Outcome are gone from the COC queue — actions, modals, RTK endpoints and strings. Their server endpoints are gone too, so this is not a hidden button over a live route.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -32,8 +32,6 @@ export type ActionId =
|
||||
| 'final-approve'
|
||||
| 'request-adjustment'
|
||||
| 'reject'
|
||||
| 'schedule-exam'
|
||||
| 'record-exam-outcome'
|
||||
| 'confirm-payment'
|
||||
| 'schedule-issuance'
|
||||
| 'issue-certificate'
|
||||
@@ -264,31 +262,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',
|
||||
@@ -379,7 +358,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.
|
||||
*/
|
||||
|
||||
@@ -54,8 +54,6 @@ import {
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
useScheduleExamMutation,
|
||||
useRecordExamOutcomeMutation,
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
@@ -96,7 +94,6 @@ import { DocumentsTab } from "../../components/DocumentsTab";
|
||||
import { FormDetailsTab } from "../../components/FormDetailsTab";
|
||||
import { ApplicantCard } from "../../components/ApplicantCard";
|
||||
import { useGetLocationsQuery } from "../../../location/api/location-api";
|
||||
import { ScheduleExamModal } from "../../components/ScheduleExamModal";
|
||||
import { computeSla } from "../../sla";
|
||||
import { reviewStaffColumns } from "./columns";
|
||||
import {
|
||||
@@ -227,9 +224,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();
|
||||
@@ -264,9 +258,6 @@ export function LicenseReviewPage() {
|
||||
const [issuanceOpen, setIssuanceOpen] = useState(false);
|
||||
const [issuanceDate, setIssuanceDate] = useState("");
|
||||
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<
|
||||
@@ -570,16 +561,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({
|
||||
@@ -1331,101 +1312,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}
|
||||
|
||||
@@ -273,6 +273,9 @@ export const am: Translations = {
|
||||
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
|
||||
next: "ቀጣይ",
|
||||
back: "ተመለስ",
|
||||
review: "ግምገማ",
|
||||
reviewHint: "ከዚህ በታች ያለውን ዝርዝር ያረጋግጡ፣ ከዚያ ፈተናውን ይፍጠሩ። ለማስተካከል ተመለስ ይጠቀሙ።",
|
||||
durationSummary: "{{days}}ቀን {{hours}}ሰ {{minutes}}ደ",
|
||||
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
|
||||
status: "ሁኔታ",
|
||||
statusPlaceholder: "የፈተና ሁኔታ",
|
||||
@@ -1033,8 +1036,6 @@ export const am: Translations = {
|
||||
finalApprove: "አጽድቅ እና ስጥ",
|
||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||
reject: "አትቀበል",
|
||||
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||
recordExamOutcome: "የፈተና ውጤት መዝግብ",
|
||||
confirmPayment: "ክፍያ አረጋግጥ",
|
||||
scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ",
|
||||
issueCertificate: "ሰርተፍኬት ስጥ",
|
||||
@@ -1087,7 +1088,6 @@ export const am: Translations = {
|
||||
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
|
||||
"assign-reviewer": "ማመልከቻ {{number}} ለተመረጠው ሹም ሰጥቶ ግምገማውን ያስጀምራል።",
|
||||
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
|
||||
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
|
||||
},
|
||||
notifications: {
|
||||
fallback: "ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።",
|
||||
|
||||
@@ -273,6 +273,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',
|
||||
@@ -1042,8 +1045,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',
|
||||
@@ -1096,7 +1097,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}}.',
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
} from '@ema-platform/api';
|
||||
import { 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
|
||||
@@ -102,6 +104,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
|
||||
@@ -180,6 +197,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.
|
||||
@@ -331,7 +367,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>
|
||||
@@ -340,7 +376,7 @@ export function CertificatesPage() {
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{humanStatus(app.status)}
|
||||
{applicationStatusLabel(app)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
102
apps/portal/src/app/features/licensing/exam-stage.spec.ts
Normal file
102
apps/portal/src/app/features/licensing/exam-stage.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
68
apps/portal/src/app/features/licensing/exam-stage.ts
Normal file
68
apps/portal/src/app/features/licensing/exam-stage.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication> {
|
||||
@@ -50,6 +51,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
|
||||
|
||||
@@ -16,6 +16,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 [
|
||||
@@ -40,7 +43,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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -46,7 +46,9 @@ import {
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useApiQuery,
|
||||
useRetakeExamMutation,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
@@ -54,6 +56,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';
|
||||
@@ -95,6 +99,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();
|
||||
@@ -267,10 +280,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,
|
||||
@@ -281,6 +316,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}`),
|
||||
}),
|
||||
|
||||
@@ -234,6 +234,15 @@ export const am: Translations = {
|
||||
licences: {
|
||||
empty: 'እስካሁን ምንም ፍቃድ አልተሰጥዎትም። ማመልከቻ ከተፈቀደና ከተከፈለ በኋላ እዚህ ይታያል።',
|
||||
},
|
||||
examStage: {
|
||||
ELIGIBLE_TO_REGISTER: "ብቁ ነዎት — ለፈተና ይመዝገቡ",
|
||||
REGISTERED: "የፈተና ቀን፦ {{date}}",
|
||||
ATTENDANCE_CONFIRMED: "መገኘት ተረጋግጧል",
|
||||
SITTING: "ፈተና በመካሄድ ላይ",
|
||||
UNDER_EVALUATION: "ፈተና ተጠናቋል — በግምገማ ላይ",
|
||||
PASSED: "አልፈዋል",
|
||||
FAILED: "አላለፉም",
|
||||
},
|
||||
status: {
|
||||
DRAFT: 'ረቂቅ',
|
||||
SUBMITTED: 'ገብቷል',
|
||||
|
||||
@@ -234,6 +234,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',
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
AssignableOfficer,
|
||||
DocumentDecision,
|
||||
DocumentReview,
|
||||
EligibleExam,
|
||||
ExportResult,
|
||||
LicenseTemplate,
|
||||
Paginated,
|
||||
@@ -650,45 +649,11 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Exam sittings valid for this application's rank — what the
|
||||
* schedule-exam picker offers, instead of every exam in the system.
|
||||
*/
|
||||
getEligibleExams: builder.query<EligibleExam[], string>({
|
||||
query: (id) => ({ url: `/license-application-review/${id}/eligible-exams` }),
|
||||
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
|
||||
@@ -1144,9 +1109,6 @@ export const {
|
||||
useApproveDocumentsMutation,
|
||||
useFinalApproveMutation,
|
||||
useRejectApplicationMutation,
|
||||
useGetEligibleExamsQuery,
|
||||
useScheduleExamMutation,
|
||||
useRecordExamOutcomeMutation,
|
||||
useRetakeExamMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
|
||||
@@ -82,7 +82,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',
|
||||
|
||||
@@ -748,12 +748,3 @@ export interface IssuedLicense {
|
||||
certificateFileKey: string | null;
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user