diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index 89b551d54..8b3eac530 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -16,6 +16,7 @@ import { Text, ThemeIcon, Title, + Tooltip, rem, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; @@ -29,8 +30,12 @@ import { IconInfoCircle, IconShieldCheck, } from '@tabler/icons-react'; -import { authStorage } from '@ema-platform/auth'; +import { authStorage, useCurrentProfile } from '@ema-platform/auth'; import { useApiQuery } from '@ema-platform/api'; +import { + useGetMySeaServiceRecordsQuery, + useGetMyMedicalCertificatesQuery, +} from '@ema-platform/api'; // --------------------------------------------------------------------------- // Mock data @@ -143,6 +148,25 @@ export function CertificatesPage() { const certificates = data?.certificates ?? []; const applications = data?.applications ?? []; + // 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. + // The three queries below only build the human-readable reason list for + // the tooltip/banner — the gate itself is the one boolean. + const { profile, eligibleForCoc: canApply } = useCurrentProfile(); + const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery(); + const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery(); + + const seafarerApproved = profile?.seafarerStatus === 'ACTIVE'; + const hasVerifiedSeaService = (seaServiceRecords ?? []).some((r) => r.status === 'VERIFIED'); + const hasVerifiedMedical = (medicalCertificates ?? []).some((c) => c.status === 'VERIFIED'); + + const missingReasons = [ + !seafarerApproved && 'Your seafarer registration is not yet approved.', + !hasVerifiedSeaService && 'No verified sea service record on file.', + !hasVerifiedMedical && 'No verified medical certificate on file.', + ].filter((r): r is string => Boolean(r)); + const openPreview = async (profileId: string, title: string) => { setLoading(true); try { @@ -186,15 +210,35 @@ export function CertificatesPage() { Certificates (CoC / CoP) Certificate of Competency and Certificate of Proficiency under STCW - + {/* Tooltip needs a hoverable child even while the button itself is + disabled, so the reason still shows on hover. */} + + + + + {!canApply && ( + }> + Not yet eligible to apply + {missingReasons.join(' ')} + + )} + {/* Info banner */} diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx index d30733fae..cd0e7bee4 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx @@ -311,8 +311,19 @@ export function SeafarerRegistrationPage() { setEmail((c) => c || profile?.address?.email || user?.email || ''); setMobile((c) => c || profile?.address?.primaryPhoneNumber || user?.phoneNumber || ''); if (profile?.address?.idNumber) setNationalIdNumber((c) => c || profile.address.idNumber); + if (profile?.address?.nationality) setNationality((c) => c ?? profile.address.nationality); + if (profile?.address?.streetAddress) setPermanentAddress((c) => c || orEmpty(profile.address.streetAddress)); + if (profile?.address?.emergencyContactName) setEmergencyName((c) => c || orEmpty(profile.address.emergencyContactName)); + if (profile?.address?.emergencyContactPhone) setEmergencyPhone((c) => c || orEmpty(profile.address.emergencyContactPhone)); + if (profile?.address?.emergencyContactRelation) setEmergencyRel((c) => c ?? profile.address.emergencyContactRelation); }, [user, profile]); + // Registration writes the profile as SEAFARER with personal details, so a + // profile in that state is a submitted registration: show it read-only + // instead of an empty wizard. "Edit" reopens the wizard on the same data. + const registered = profile?.type === 'SEAFARER' && !!profile.firstName && !!profile.dob; + const [editing, setEditing] = useState(false); + const canNext = () => { if (active === 0) return !!firstName.en.trim() && !!lastName.en.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim(); if (active === 1) return !!mobile.trim() && !!email.trim() && !!locationId; @@ -361,7 +372,9 @@ export function SeafarerRegistrationPage() { }).unwrap(); notify.success(`Registration submitted! Profile ID: ${profileId.slice(0, 8).toUpperCase()}`); - navigate('/seafarer-registry'); + setEditing(false); + setActive(0); + setCompleted([]); } catch { notify.error('Submission failed. Please try again.'); } finally { @@ -369,10 +382,102 @@ export function SeafarerRegistrationPage() { } }; + const review = ( + + + Personal Information + + + + + + + + + + + + + + + + + Contact Details + + + + + + + + {emergencyName && ( + <> + + Emergency Contact + + + + + + + )} + + + + Documents + + {DOC_SLOTS.map((slot) => ( + + {files[slot.key] ? ( + + ) : ( + + )} +
+ + {slot.label} + {slot.required && !files[slot.key] && *} + + {files[slot.key] && ( + {files[slot.key]!.name} + )} +
+
+ ))} +
+
+
+ ); + const stepLabel = STEPS[active]?.label ?? ''; const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck]; const StepIcon = stepIcons[active]; + if (registered && !editing) { + const status = profile.seafarerStatus ?? 'PENDING'; + return ( + + +
+ Seafarer Registration + + {profile.seafarerNumber + ? `Seafarer ID ${profile.seafarerNumber}` + : 'Submitted — a Seafarer ID is issued once EMA approves your registration.'} + +
+ + + {status} + + + +
+ {review} +
+ ); + } + return ( {/* Page header */} @@ -498,72 +603,7 @@ export function SeafarerRegistrationPage() { )} {/* ── Step 4: Review & Submit ─────────────────────────────────── */} - {active === 3 && ( - - - Personal Information - - - - - - - - - - - - - - - - - Contact Details - - - - - - - - {emergencyName && ( - <> - - Emergency Contact - - - - - - - )} - - - - Documents - - {DOC_SLOTS.map((slot) => ( - - {files[slot.key] ? ( - - ) : ( - - )} -
- - {slot.label} - {slot.required && !files[slot.key] && *} - - {files[slot.key] && ( - {files[slot.key]!.name} - )} -
-
- ))} -
-
-
- )} + {active === 3 && review} {/* Navigation buttons */} diff --git a/libs/auth/src/lib/hooks/useCurrentProfile.ts b/libs/auth/src/lib/hooks/useCurrentProfile.ts index 803f28e8d..6cea8dc9f 100644 --- a/libs/auth/src/lib/hooks/useCurrentProfile.ts +++ b/libs/auth/src/lib/hooks/useCurrentProfile.ts @@ -91,6 +91,13 @@ export interface ProfileMeResponse { * `usePermissions()`. */ permissions?: string[]; + /** + * Whether the profile can apply for a CoC/CoP right now: seafarer + * registration approved, plus a verified sea service record and a + * verified medical certificate. Computed server-side so the "Apply" + * button and the API's own eligibility check can never disagree. + */ + eligibleForCoc: boolean; } const profileApi = baseApi @@ -167,6 +174,7 @@ export function useCurrentProfile() { refetch, completeness: data?.completeness ?? 0, missing, + eligibleForCoc: data?.eligibleForCoc ?? false, /** * True when nothing the requirement asks for is still blank. Unknown * profile (still loading) reads as not-ready, so a caller never submits