diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx
index c4578d308..4ae3fd48e 100644
--- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx
+++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx
@@ -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 (
+
+
+ {label}
+
+ {value || "—"}
+
+ );
+}
+
function ExamForm({
editing,
certOptions,
@@ -77,45 +97,48 @@ function ExamForm({
editing?.cuttingPoint ?? 0,
);
const [status, setStatus] = useState(editing?.status ?? null);
- const [activeTab, setActiveTab] = useState("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 (
diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts
new file mode 100644
index 000000000..1f85f4060
--- /dev/null
+++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts
@@ -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',
+ );
+ });
+});
diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts
new file mode 100644
index 000000000..43bda456e
--- /dev/null
+++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts
@@ -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;
+}
diff --git a/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx b/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx
deleted file mode 100644
index 320f5a6e2..000000000
--- a/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx
+++ /dev/null
@@ -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(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 (
-
-
-
- {t('review.scheduleExam.intro', {
- defaultValue:
- 'Make a sitting available to {{applicant}}. They register for it themselves from the portal.',
- applicant: applicantName,
- })}
-
-
- {!isLoading && options.length === 0 ? (
- }>
- {t(
- 'review.scheduleExam.noSessions',
- 'No exam sessions for this rank exist yet. Create one in the Exams area first.',
- )}
-
- ) : (
-
- )}
-
-
- {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.',
- )}
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts
index 8511b7b29..206d10a8d 100644
--- a/apps/backoffice/src/app/features/license-review/config/actions.ts
+++ b/apps/backoffice/src/app/features/license-review/config/actions.ts
@@ -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.
*/
diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx
index 1977689ae..8551f3cca 100644
--- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx
+++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx
@@ -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(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();
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}
/>
- 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),
- });
- }
- }}
- />
-
- setExamOutcomeOpen(false)}
- title={t("review.actions.recordExamOutcome", "Record exam outcome")}
- >
-
-
- {t(
- "review.examOutcome.intro",
- "Record the published result. A pass makes the certificate fee due; a fail leaves the application open for a retake.",
- )}
-
- setExamScore(typeof v === "number" ? v : undefined)}
- min={0}
- />
-
-
- run(
- async () => {
- await recordExamOutcome({
- id,
- passed: true,
- score: examScore,
- }).unwrap();
- setExamOutcomeOpen(false);
- setExamScore(undefined);
- },
- t("review.done.examPassed", "Exam result recorded — passed"),
- )
- }
- >
-
-
-
- run(
- async () => {
- await recordExamOutcome({
- id,
- passed: false,
- score: examScore,
- }).unwrap();
- setExamOutcomeOpen(false);
- setExamScore(undefined);
- },
- t("review.done.examFailed", "Exam result recorded — not passed"),
- )
- }
- >
-
-
-
-
-
+ {/* 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. */}
= {
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 = {
+ 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(
+ { 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() {
{formatDate(app.submitted)}
- {humanStatus(app.status)}
+ {applicationStatusLabel(app)}
@@ -340,7 +376,7 @@ export function CertificatesPage() {
variant="light"
size="sm"
>
- {humanStatus(app.status)}
+ {applicationStatusLabel(app)}
diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx
index dc229e7a3..392531ee1 100644
--- a/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx
+++ b/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx
@@ -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;
diff --git a/apps/portal/src/app/features/licensing/components/ExamStageActions.tsx b/apps/portal/src/app/features/licensing/components/ExamStageActions.tsx
index b19ec09f4..dc6f90978 100644
--- a/apps/portal/src/app/features/licensing/components/ExamStageActions.tsx
+++ b/apps/portal/src/app/features/licensing/components/ExamStageActions.tsx
@@ -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 (
-