Merge branch 'estif-branch-1' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-09-01 09:44:39 +03:00
27 changed files with 867 additions and 389 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,7 @@ export function SelectField(p: FieldProps & { options: { value: string; label: s
);
}
export function DateField(p: FieldProps) {
export function DateField(p: FieldProps & { minDate?: Date | string; maxDate?: Date | string }) {
return (
<Col span={p.span}>
<AmharicDatePicker
@@ -70,6 +70,8 @@ export function DateField(p: FieldProps) {
disabled={p.disabled}
required={p.required}
dateFormat="date"
minDate={p.minDate}
maxDate={p.maxDate}
value={(p.form[p.name] as string) ?? ''}
onChange={(v) => p.set(p.name, v)}
/>

View File

@@ -220,6 +220,10 @@ export function EmergencyContactStep(p: StepProps) {
name="medicalIssueDate"
label="Issue Date"
required
// Bounded here rather than only at submit: the calendar is the one
// place the applicant can see why a day is refused, and the server's
// rejection otherwise only surfaces five steps later.
maxDate={new Date()}
description="Cannot be a future date. Validity is calculated from this: two years, or one year if you are under 18."
/>
</Grid>

View File

@@ -80,6 +80,11 @@ function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration
return Object.fromEntries(ANSWER_KEYS.map((k) => [k, registration[k]])) as SaveSeafarerRegistration;
}
/** Today as `yyyy-mm-dd` in the browser's own zone — en-CA is that format. */
function todayDate(): string {
return new Date().toLocaleDateString('en-CA');
}
function blank(value: unknown): boolean {
return value === null || value === undefined || value === '' || value === false;
}
@@ -258,6 +263,10 @@ export function SeafarerRegistrationPage() {
function set(key: AnswerKey, value: unknown) {
setForm((prev) => ({ ...prev, [key]: value }));
// The submit refusal names what was wrong at the time it was refused.
// Leaving it on screen while the applicant corrects it reads as the
// correction having been ignored.
if (issues.length) setIssues([]);
setErrors((prev) => {
if (!prev[key]) return prev;
const next = { ...prev };
@@ -280,6 +289,9 @@ export function SeafarerRegistrationPage() {
found.weightKg = `Enter a weight between ${weightKg.min} and ${weightKg.max} kg.`;
}
}
if (index === 2 && (form.medicalIssueDate ?? '').slice(0, 10) > todayDate()) {
found.medicalIssueDate = 'The issue date cannot be in the future.';
}
setErrors(found);
const missingKeys = Object.keys(found) as AnswerKey[];
if (missingKeys.length) {
@@ -323,19 +335,23 @@ export function SeafarerRegistrationPage() {
}
async function goToStep(target: number) {
if (target <= active) {
setActive(target);
return;
}
// Going forward validates every step passed over, so a jump cannot skip a
// required field; the walk stops on the first step that fails.
for (let step = active; step < target; step++) {
if (!readOnly && !validateStep(step)) {
setActive(step);
return;
// Saved before anything can turn the navigation around. `form` is the only
// copy of what was typed, so validating first — as this used to — threw the
// edit away on every blocked step and every step back: an applicant fixing
// a field the submit check rejected watched the correction vanish. A draft
// takes any subset of the answers, so persisting an incomplete one is safe.
const saved = await saveAnswers();
if (target > active) {
if (!saved) return;
// Going forward validates every step passed over, so a jump cannot skip a
// required field; the walk stops on the first step that fails.
for (let step = active; step < target; step++) {
if (!readOnly && !validateStep(step)) {
setActive(step);
return;
}
}
}
if (!(await saveAnswers())) return;
setErrors({});
setActive(target);
}
@@ -343,8 +359,8 @@ export function SeafarerRegistrationPage() {
async function handleSubmit() {
if (!registration) return;
setIssues([]);
if (!readOnly && !validateStep(4)) return;
if (!(await saveAnswers())) return;
if (!readOnly && !validateStep(4)) return;
try {
await submit(registration.id).unwrap();
notifications.show({
@@ -510,7 +526,7 @@ export function SeafarerRegistrationPage() {
)}
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => setActive((s) => Math.max(0, s - 1))} disabled={active === 0}>
<Button variant="default" onClick={() => goToStep(Math.max(0, active - 1))} disabled={active === 0}>
Back
</Button>
{active < STEPS.length - 1 ? (

View File

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

View File

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