mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-01 20:33:28 +00:00
Merge pull request #52 from Tria-plc/fix/coc-exam-workflow-defects
Fix/coc exam workflow defects
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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}}።",
|
||||
|
||||
@@ -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}}.',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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: 'ገብቷል',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user