mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 04:15:44 +00:00
Merge branch 'estif-branch-1' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user