mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 12:18:12 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -407,23 +407,23 @@ export function LicenseApplicationPage() {
|
||||
const currentStep = steps[active];
|
||||
|
||||
/**
|
||||
* Checks the current step before moving on.
|
||||
* Checks one step before moving past it.
|
||||
*
|
||||
* The server rejects an incomplete application anyway, but only at submit —
|
||||
* by then the applicant has walked through every step and has to hunt for
|
||||
* what was missing. Validating per step points at the field directly.
|
||||
*
|
||||
* Takes the step rather than reading `currentStep`, so a jump ahead can
|
||||
* check each step it passes over instead of only the one being left.
|
||||
*/
|
||||
async function validateCurrentStep(): Promise<boolean> {
|
||||
async function validateStep(index: number): Promise<boolean> {
|
||||
const step = steps[index];
|
||||
// The wizard does not render until the configuration has loaded, but this
|
||||
// is declared above that guard, so narrow it here too.
|
||||
if (!currentStep || !config) return true;
|
||||
if (!step || !config) return true;
|
||||
|
||||
if (currentStep.kind === "sections") {
|
||||
const errors = validateSections(
|
||||
currentStep.sections,
|
||||
draft,
|
||||
i18n.language,
|
||||
);
|
||||
if (step.kind === "sections") {
|
||||
const errors = validateSections(step.sections, draft, i18n.language);
|
||||
setFieldErrors(errors);
|
||||
const count = Object.keys(errors).length;
|
||||
if (count > 0) {
|
||||
@@ -437,7 +437,7 @@ export function LicenseApplicationPage() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentStep.kind === "staff") {
|
||||
if (step.kind === "staff") {
|
||||
const missing = config.staffRoleRequirements
|
||||
.filter(
|
||||
(role) =>
|
||||
@@ -461,7 +461,7 @@ export function LicenseApplicationPage() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentStep.kind === "documents") {
|
||||
if (step.kind === "documents") {
|
||||
const supplied = new Set(
|
||||
attachments.filter((a) => a.files?.length).map((a) => a.documentKey),
|
||||
);
|
||||
@@ -495,6 +495,11 @@ export function LicenseApplicationPage() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The step the applicant is on — what `Continue` validates. */
|
||||
async function validateCurrentStep(): Promise<boolean> {
|
||||
return validateStep(active);
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
// A locked step during an adjustment round has nothing to validate.
|
||||
if (!readOnly && !(await validateCurrentStep())) return;
|
||||
@@ -506,19 +511,39 @@ export function LicenseApplicationPage() {
|
||||
setActive((s) => Math.min(steps.length - 1, s + 1));
|
||||
}
|
||||
|
||||
/** Going back is always allowed; going forward validates each step passed. */
|
||||
/**
|
||||
* Going back is always allowed; going forward validates every step passed.
|
||||
*
|
||||
* `target` used to be discarded on the forward path — the handler validated
|
||||
* the current step and then advanced by exactly one, so clicking "4" from
|
||||
* step 1 landed on step 2. Two steps then showed the same content one click
|
||||
* apart, which reads as a broken wizard rather than a refused jump, and made
|
||||
* the later sections look absent entirely.
|
||||
*
|
||||
* Each step between here and `target` is validated and saved in order, so a
|
||||
* jump ahead cannot skip a required field the way a plain `setActive` would.
|
||||
* The walk stops at the first step that fails, leaving the applicant on it
|
||||
* with its errors showing.
|
||||
*/
|
||||
async function goToStep(target: number) {
|
||||
if (target <= active) {
|
||||
setActive(target);
|
||||
return;
|
||||
}
|
||||
if (!readOnly && !(await validateCurrentStep())) return;
|
||||
if (currentStep?.kind === "sections") {
|
||||
for (const section of currentStep.sections)
|
||||
await saveSection(section.key);
|
||||
|
||||
for (let step = active; step < target; step++) {
|
||||
if (!readOnly && !(await validateStep(step))) {
|
||||
setActive(step);
|
||||
return;
|
||||
}
|
||||
const passed = steps[step];
|
||||
if (passed?.kind === "sections") {
|
||||
for (const section of passed.sections) await saveSection(section.key);
|
||||
}
|
||||
}
|
||||
|
||||
setFieldErrors({});
|
||||
setActive(active + 1);
|
||||
setActive(target);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -59,7 +59,7 @@ export function RequireSeafarerProfile({ children }: { children: React.ReactNode
|
||||
const { t } = useTranslation();
|
||||
const { typeCode } = useParams();
|
||||
const { pathname } = useLocation();
|
||||
const { isLoading, isFetching, error, gapsFor } = useCurrentProfile();
|
||||
const { isLoading, isFetching, error, gapsFor, profile } = useCurrentProfile();
|
||||
|
||||
// Shared wizard route — only the seafarer type is gated here.
|
||||
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
|
||||
@@ -81,6 +81,15 @@ export function RequireSeafarerProfile({ children }: { children: React.ReactNode
|
||||
return <PageLoader label={t('profileGate.checkingProfile')} height={350} />;
|
||||
}
|
||||
|
||||
// Already registered: the number is permanent and the server now refuses a
|
||||
// second registration outright (409 seafarer_already_registered). Sending
|
||||
// them on beats opening a wizard whose first act — creating the draft — is
|
||||
// the call that fails. Checked after the loading guard so an unresolved
|
||||
// profile is never read as "not registered".
|
||||
if (profile?.seafarerNumber) {
|
||||
return <Navigate to="/seaman-book" replace />;
|
||||
}
|
||||
|
||||
// A failed lookup must not lock anyone out — the server still refuses the
|
||||
// application for a profile it can't fill in from.
|
||||
if (error) return <>{children}</>;
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { NavItem } from "@ema-platform/ui";
|
||||
import {
|
||||
BrandMark,
|
||||
logout,
|
||||
useCurrentProfile,
|
||||
usePermissions,
|
||||
LICENSE_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
@@ -147,21 +148,30 @@ export function PortalLayout() {
|
||||
refetchOnMountOrArgChange: false,
|
||||
});
|
||||
const { permissions: granted, known } = usePermissions();
|
||||
// A seafarer registers once; the number is permanent. Once it exists the
|
||||
// registration item is dropped rather than left to bounce off
|
||||
// RequireSeafarerProfile's redirect.
|
||||
const { profile } = useCurrentProfile();
|
||||
const registered = Boolean(profile?.seafarerNumber);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const translated = NAV_SECTIONS.map((section) => ({
|
||||
label: section.label,
|
||||
items: section.items.map(({ i18nKey, ...rest }) => ({
|
||||
...rest,
|
||||
label: t(i18nKey),
|
||||
badge:
|
||||
rest.to === "/notifications" && unseen?.count ? unseen.count : undefined,
|
||||
})),
|
||||
items: section.items
|
||||
.filter((item) => !(registered && item.to === "/seafarer-registration"))
|
||||
.map(({ i18nKey, ...rest }) => ({
|
||||
...rest,
|
||||
label: t(i18nKey),
|
||||
badge:
|
||||
rest.to === "/notifications" && unseen?.count
|
||||
? unseen.count
|
||||
: undefined,
|
||||
})),
|
||||
}));
|
||||
// Unfiltered until the grant list has loaded — same fail-open rule as
|
||||
// RequirePermission: a moment of extra nav beats a flash of empty nav.
|
||||
return known ? filterByPermissions(translated, granted) : translated;
|
||||
}, [t, unseen?.count, granted, known]);
|
||||
}, [t, unseen?.count, granted, known, registered]);
|
||||
|
||||
// Breadcrumb trail
|
||||
const segments = location.pathname.split("/").filter(Boolean);
|
||||
|
||||
Reference in New Issue
Block a user