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
- }
- rightSection={ }
- onClick={() => navigate('/certificates/apply')}
+
- Apply for CoC / CoP
-
+ {/* Tooltip needs a hoverable child even while the button itself is
+ disabled, so the reason still shows on hover. */}
+
+ }
+ rightSection={ }
+ onClick={() => navigate('/certificates/apply')}
+ disabled={!canApply}
+ >
+ Apply for CoC / CoP
+
+
+
+ {!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}
+
+ setEditing(true)}>Edit details
+
+
+ {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