mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Implement seafarer profile requirement gate to ensure complete profile before registration
This commit is contained in:
@@ -50,6 +50,7 @@ import {
|
|||||||
type ValidationIssue,
|
type ValidationIssue,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { ModalFooter } from '@ema-platform/ui';
|
import { ModalFooter } from '@ema-platform/ui';
|
||||||
|
import { useCurrentProfile } from '@ema-platform/auth';
|
||||||
import { ConfigDrivenSection } from '../components/ConfigDrivenSection';
|
import { ConfigDrivenSection } from '../components/ConfigDrivenSection';
|
||||||
import { DocumentSlots } from '../components/DocumentSlots';
|
import { DocumentSlots } from '../components/DocumentSlots';
|
||||||
import { StaffEvidence } from '../components/StaffEvidence';
|
import { StaffEvidence } from '../components/StaffEvidence';
|
||||||
@@ -65,6 +66,7 @@ export function LicenseApplicationPage() {
|
|||||||
|
|
||||||
const { data: config, isLoading: loadingConfig } =
|
const { data: config, isLoading: loadingConfig } =
|
||||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||||
|
const { profile } = useCurrentProfile();
|
||||||
const [createApplication] = useCreateApplicationMutation();
|
const [createApplication] = useCreateApplicationMutation();
|
||||||
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
||||||
|
|
||||||
@@ -112,6 +114,24 @@ export function LicenseApplicationPage() {
|
|||||||
if (detail?.application?.formData) setDraft(detail.application.formData);
|
if (detail?.application?.formData) setDraft(detail.application.formData);
|
||||||
}, [detail?.application?.id, detail?.application?.adjustmentRound]);
|
}, [detail?.application?.id, detail?.application?.adjustmentRound]);
|
||||||
|
|
||||||
|
// Nationality is already on file from the profile's Address tab — carry it
|
||||||
|
// into whichever section the form config puts a `nationality` field in,
|
||||||
|
// rather than asking again. Only fills a blank; a value already on the
|
||||||
|
// draft (the applicant's own edit, or one the server saved) is left alone.
|
||||||
|
useEffect(() => {
|
||||||
|
const nationality = profile?.address?.nationality;
|
||||||
|
if (!nationality || !config) return;
|
||||||
|
const section = config.licenseType.formSchema.sections.find((s) =>
|
||||||
|
s.fields.some((f) => f.key === 'nationality'),
|
||||||
|
);
|
||||||
|
if (!section) return;
|
||||||
|
setDraft((prev) =>
|
||||||
|
prev[section.key]?.nationality
|
||||||
|
? prev
|
||||||
|
: { ...prev, [section.key]: { ...prev[section.key], nationality } },
|
||||||
|
);
|
||||||
|
}, [profile?.address?.nationality, config]);
|
||||||
|
|
||||||
const application = detail?.application;
|
const application = detail?.application;
|
||||||
const isAdjusting = application?.status === 'RESUBMIT_REQUIRED';
|
const isAdjusting = application?.status === 'RESUBMIT_REQUIRED';
|
||||||
const openRemarks = detail?.openRemarks ?? [];
|
const openRemarks = detail?.openRemarks ?? [];
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Center, Loader } from '@mantine/core';
|
||||||
|
import { Navigate, useLocation, useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import {
|
||||||
|
PROFILE_FIELD_SECTION,
|
||||||
|
useCurrentProfile,
|
||||||
|
type ProfileRequirement,
|
||||||
|
} from '@ema-platform/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seafarer registration is filled in from the profile (nationality, ID,
|
||||||
|
* names, contact details) — the server refuses an application missing them,
|
||||||
|
* so they're asked for up front instead of at submit time.
|
||||||
|
*
|
||||||
|
* Only the fields the Personal, Maritime Profile and Address tabs actually
|
||||||
|
* mark required — matches `profileSchema` / `addressSchema`, so the gate is
|
||||||
|
* always satisfiable by finishing those tabs and never blocks on an optional
|
||||||
|
* field (place of birth, region/city/woreda, emergency contact) the forms
|
||||||
|
* don't star.
|
||||||
|
*/
|
||||||
|
export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
|
||||||
|
fields: [
|
||||||
|
'firstName',
|
||||||
|
'middleName',
|
||||||
|
'lastName',
|
||||||
|
'gender',
|
||||||
|
'dob',
|
||||||
|
'maritalStatus',
|
||||||
|
'professionId',
|
||||||
|
'idType',
|
||||||
|
'idNumber',
|
||||||
|
'nationality',
|
||||||
|
'primaryPhoneNumber',
|
||||||
|
'email',
|
||||||
|
],
|
||||||
|
reason:
|
||||||
|
'Seafarer registration is built from your profile — these details fill it in for you.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an applicant with an incomplete profile to `/profile` before they can
|
||||||
|
* reach seafarer registration. Wraps `/seafarer-registration` directly and
|
||||||
|
* `/licensing/:typeCode/apply` when `typeCode` is the seafarer type — the
|
||||||
|
* latter is the shared wizard route every licence type renders through, so
|
||||||
|
* without it the gate is a decoration a deep link skips.
|
||||||
|
*
|
||||||
|
* Fires before the wizard starts, not mid-application, so nothing is lost —
|
||||||
|
* unlike the case `ProfileRequirementGate`'s doc comment warns against
|
||||||
|
* (mid-flow redirects on the old, deleted setup wizard).
|
||||||
|
*/
|
||||||
|
export function RequireSeafarerProfile({ children }: { children: React.ReactNode }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { typeCode } = useParams();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const { isLoading, isFetching, error, gapsFor } = useCurrentProfile();
|
||||||
|
|
||||||
|
// Shared wizard route — only the seafarer type is gated here.
|
||||||
|
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
|
||||||
|
const gaps = gated ? gapsFor(SEAFARER_PROFILE_REQUIREMENT) : [];
|
||||||
|
const redirecting = gated && !isLoading && !error && !isFetching && gaps.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!redirecting) return;
|
||||||
|
const fields = gaps.map((field) => t(`profileFields.${field}`, field)).join(', ');
|
||||||
|
notify.info(t('profileGate.seafarerRedirect', { fields }));
|
||||||
|
// Fire once per redirect, not on every render while gaps/gapsFor are
|
||||||
|
// recreated — the toast content is captured at the moment it fires.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [redirecting, pathname]);
|
||||||
|
|
||||||
|
if (!gated) return <>{children}</>;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center h={200}>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}</>;
|
||||||
|
|
||||||
|
if (gaps.length === 0) return <>{children}</>;
|
||||||
|
|
||||||
|
// Gaps while a save is still landing are not an answer yet. Saving a
|
||||||
|
// profile tab invalidates this query and the applicant may already be
|
||||||
|
// headed back here in the same tick — deciding on the pre-save cache would
|
||||||
|
// bounce them off the screen they just finished.
|
||||||
|
if (isFetching) {
|
||||||
|
return (
|
||||||
|
<Center h={200}>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = PROFILE_FIELD_SECTION[gaps[0]];
|
||||||
|
return <Navigate to={`/profile#${target}`} replace />;
|
||||||
|
}
|
||||||
@@ -146,6 +146,7 @@ export function ProfilePage() {
|
|||||||
isLoading: profileResolving,
|
isLoading: profileResolving,
|
||||||
completeness,
|
completeness,
|
||||||
missing,
|
missing,
|
||||||
|
refetch: refetchProfile,
|
||||||
} = useCurrentProfile();
|
} = useCurrentProfile();
|
||||||
const [updateProfile] = useApiMutation<unknown>();
|
const [updateProfile] = useApiMutation<unknown>();
|
||||||
const [saveMyAddress, { isLoading: isSavingAddress }] = useSaveMyAddressMutation();
|
const [saveMyAddress, { isLoading: isSavingAddress }] = useSaveMyAddressMutation();
|
||||||
@@ -280,6 +281,9 @@ export function ProfilePage() {
|
|||||||
email: updatedUser.email,
|
email: updatedUser.email,
|
||||||
phoneNumber: updatedUser.phoneNumber,
|
phoneNumber: updatedUser.phoneNumber,
|
||||||
});
|
});
|
||||||
|
// Email/phone feed the profile's completeness check too — without this
|
||||||
|
// `missing` and every requirement gate stay stale until a reload.
|
||||||
|
refetchProfile();
|
||||||
notify.success(t('profile.profileUpdated'));
|
notify.success(t('profile.profileUpdated'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -311,6 +315,10 @@ export function ProfilePage() {
|
|||||||
body: values,
|
body: values,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
|
// This endpoint doesn't invalidate the `CurrentProfile` tag (unlike
|
||||||
|
// the address save below) — without this, `missing` stays stale until
|
||||||
|
// a reload.
|
||||||
|
refetchProfile();
|
||||||
notify.success('Profile updated');
|
notify.success('Profile updated');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ export const am: Translations = {
|
|||||||
title_other: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃዎች እንፈልጋለን',
|
title_other: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃዎች እንፈልጋለን',
|
||||||
addDetails: 'እነዚህን መረጃዎች ጨምር',
|
addDetails: 'እነዚህን መረጃዎች ጨምር',
|
||||||
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
|
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
|
||||||
|
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
|
||||||
|
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
|
||||||
},
|
},
|
||||||
|
|
||||||
profileSections: {
|
profileSections: {
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export const en = {
|
|||||||
title_other: 'We need {{count}} more details before you continue',
|
title_other: 'We need {{count}} more details before you continue',
|
||||||
addDetails: 'Add these details',
|
addDetails: 'Add these details',
|
||||||
viewProfile: 'View full profile',
|
viewProfile: 'View full profile',
|
||||||
|
seafarerReason:
|
||||||
|
'Seafarer registration is built from your profile — these details fill it in for you.',
|
||||||
|
seafarerRedirect: 'Finish your profile to register as a seafarer. Still needed: {{fields}}',
|
||||||
},
|
},
|
||||||
|
|
||||||
profileSections: {
|
profileSections: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
// Portal feature pages
|
// Portal feature pages
|
||||||
import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
|
import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
|
||||||
import { RequireOperations } from "./features/onboarding/components/RequireOperations";
|
import { RequireOperations } from "./features/onboarding/components/RequireOperations";
|
||||||
|
import { RequireSeafarerProfile } from "./features/profile/components/RequireSeafarerProfile";
|
||||||
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
||||||
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||||
import { SupportPage } from "./features/support/pages/SupportPage";
|
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||||
@@ -114,7 +115,11 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "/payments/failure", element: <PaymentFailurePage /> },
|
{ path: "/payments/failure", element: <PaymentFailurePage /> },
|
||||||
{
|
{
|
||||||
path: "/licensing/:typeCode/apply",
|
path: "/licensing/:typeCode/apply",
|
||||||
element: <LicenseApplicationPage />,
|
element: (
|
||||||
|
<RequireSeafarerProfile>
|
||||||
|
<LicenseApplicationPage />
|
||||||
|
</RequireSeafarerProfile>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/licensing/:typeCode/applications/:applicationId",
|
path: "/licensing/:typeCode/applications/:applicationId",
|
||||||
@@ -128,7 +133,14 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Seafarer
|
// Seafarer
|
||||||
{ path: "/seafarer-registration", element: <SeafarerRegistrationPage /> },
|
{
|
||||||
|
path: "/seafarer-registration",
|
||||||
|
element: (
|
||||||
|
<RequireSeafarerProfile>
|
||||||
|
<SeafarerRegistrationPage />
|
||||||
|
</RequireSeafarerProfile>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ path: "/seafarer/records", element: <MySeaRecordsPage /> },
|
{ path: "/seafarer/records", element: <MySeaRecordsPage /> },
|
||||||
{ path: "/exams", element: <ExamsPage /> },
|
{ path: "/exams", element: <ExamsPage /> },
|
||||||
// The public-facing registry was a hardcoded mock and does not belong in
|
// The public-facing registry was a hardcoded mock and does not belong in
|
||||||
|
|||||||
@@ -38,21 +38,32 @@ export const PROFILE_FIELDS = [
|
|||||||
|
|
||||||
export type ProfileField = (typeof PROFILE_FIELDS)[number];
|
export type ProfileField = (typeof PROFILE_FIELDS)[number];
|
||||||
|
|
||||||
/** Which `/profile` tab collects a field — used to build deep links. */
|
/**
|
||||||
export const PROFILE_FIELD_SECTION: Record<ProfileField, 'personal' | 'address' | 'emergency'> = {
|
* Which `/profile` tab collects a field — used to build deep links.
|
||||||
firstName: 'personal',
|
*
|
||||||
middleName: 'personal',
|
* `personal` here means the Personal tab (account details: name, username,
|
||||||
lastName: 'personal',
|
* email, phone), which is `/profile`'s `'personal'` panel. Name/DOB/gender/
|
||||||
gender: 'personal',
|
* profession live on the *Maritime Profile* panel instead, whose tab key is
|
||||||
dob: 'personal',
|
* `'profile'` — easy to conflate with the field-section name of the same
|
||||||
pob: 'personal',
|
* word, so it's called out here rather than left implicit.
|
||||||
maritalStatus: 'personal',
|
*/
|
||||||
professionId: 'personal',
|
export const PROFILE_FIELD_SECTION: Record<
|
||||||
|
ProfileField,
|
||||||
|
'personal' | 'profile' | 'address' | 'emergency'
|
||||||
|
> = {
|
||||||
|
firstName: 'profile',
|
||||||
|
middleName: 'profile',
|
||||||
|
lastName: 'profile',
|
||||||
|
gender: 'profile',
|
||||||
|
dob: 'profile',
|
||||||
|
pob: 'profile',
|
||||||
|
maritalStatus: 'profile',
|
||||||
|
professionId: 'profile',
|
||||||
idType: 'address',
|
idType: 'address',
|
||||||
idNumber: 'address',
|
idNumber: 'address',
|
||||||
nationality: 'address',
|
nationality: 'address',
|
||||||
primaryPhoneNumber: 'address',
|
primaryPhoneNumber: 'personal',
|
||||||
email: 'address',
|
email: 'personal',
|
||||||
regionId: 'address',
|
regionId: 'address',
|
||||||
cityId: 'address',
|
cityId: 'address',
|
||||||
subCityId: 'address',
|
subCityId: 'address',
|
||||||
|
|||||||
Reference in New Issue
Block a user