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

This commit is contained in:
Estifo77
2026-08-18 14:54:05 +03:00
42 changed files with 17179 additions and 40 deletions

View File

@@ -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 (

View File

@@ -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}</>;

View File

@@ -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);