feat(certificates): enhance eligibility checks for CoC/CoP application with tooltips and alerts

This commit is contained in:
Nati
2026-08-17 08:19:33 +00:00
parent ad9b14d716
commit 3e3d20174c
3 changed files with 166 additions and 74 deletions

View File

@@ -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() {
<Title order={3}>Certificates (CoC / CoP)</Title>
<Text fz="sm" c="dimmed">Certificate of Competency and Certificate of Proficiency under STCW</Text>
</div>
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/certificates/apply')}
<Tooltip
label={missingReasons.join(' ')}
disabled={canApply}
multiline
w={260}
events={{ hover: true, focus: true, touch: true }}
>
Apply for CoC / CoP
</Button>
{/* Tooltip needs a hoverable child even while the button itself is
disabled, so the reason still shows on hover. */}
<span>
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/certificates/apply')}
disabled={!canApply}
>
Apply for CoC / CoP
</Button>
</span>
</Tooltip>
</Group>
{!canApply && (
<Alert variant="light" color="orange" icon={<IconInfoCircle size={15} />}>
<Text fz="sm" fw={600}>Not yet eligible to apply</Text>
<Text fz="xs" c="dimmed">{missingReasons.join(' ')}</Text>
</Alert>
)}
{/* Info banner */}
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="md" wrap="nowrap">

View File

@@ -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 = (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Personal Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="First Name" value={`${firstName.en}${firstName.am ? ` / ${firstName.am}` : ''}`} />
<ReviewRow label="Middle Name" value={`${middleName.en}${middleName.am ? ` / ${middleName.am}` : ''}`} />
<ReviewRow label="Last Name" value={`${lastName.en}${lastName.am ? ` / ${lastName.am}` : ''}`} />
<ReviewRow label="Gender" value={gender ?? ''} />
<ReviewRow label="Date of Birth" value={`${dob?.toLocaleDateString('en-US') ?? ''}${dob ? ` (${toEthiopicDateLabel(dob)})` : ''}`} />
<ReviewRow label="Place of Birth" value={placeOfBirth} />
<ReviewRow label="Nationality" value={nationality ?? ''} />
<ReviewRow label="Marital Status" value={maritalStatus ?? ''} />
<ReviewRow label="National ID No." value={nationalIdNumber} />
<ReviewRow label="Passport No." value={passportNumber} />
<ReviewRow label="Passport Expiry" value={passportExpiry} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Contact Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Mobile" value={mobile} />
<ReviewRow label="Email" value={email} />
<ReviewRow label="Location" value={locationId ?? ''} />
<ReviewRow label="Permanent Address" value={permanentAddress} />
<ReviewRow label="Current Address" value={currentAddress} />
</SimpleGrid>
{emergencyName && (
<>
<Divider mt="md" mb="sm" />
<Text fw={600} fz="sm" mb="sm">Emergency Contact</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Name" value={emergencyName} />
<ReviewRow label="Relationship" value={emergencyRel ?? ''} />
<ReviewRow label="Phone" value={emergencyPhone} />
</SimpleGrid>
</>
)}
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap="xs" align="center">
{files[slot.key] ? (
<IconCircleCheck size={18} color="var(--mantine-color-teal-6)" />
) : (
<Box style={{ width: 18, height: 18, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)', flexShrink: 0 }} />
)}
<div>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label}
{slot.required && !files[slot.key] && <Text span c="red" ml={3}>*</Text>}
</Text>
{files[slot.key] && (
<Text fz="xs" c="dimmed" truncate maw={160}>{files[slot.key]!.name}</Text>
)}
</div>
</Group>
))}
</SimpleGrid>
</Paper>
</Stack>
);
const stepLabel = STEPS[active]?.label ?? '';
const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck];
const StepIcon = stepIcons[active];
if (registered && !editing) {
const status = profile.seafarerStatus ?? 'PENDING';
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Title order={3}>Seafarer Registration</Title>
<Text fz="sm" c="dimmed">
{profile.seafarerNumber
? `Seafarer ID ${profile.seafarerNumber}`
: 'Submitted — a Seafarer ID is issued once EMA approves your registration.'}
</Text>
</div>
<Group gap="sm">
<Badge variant="light" color={status === 'ACTIVE' ? 'teal' : status === 'PENDING' ? 'yellow' : 'red'}>
{status}
</Badge>
<Button variant="default" onClick={() => setEditing(true)}>Edit details</Button>
</Group>
</Group>
{review}
</Stack>
);
}
return (
<Stack gap="md">
{/* Page header */}
@@ -498,72 +603,7 @@ export function SeafarerRegistrationPage() {
)}
{/* ── Step 4: Review & Submit ─────────────────────────────────── */}
{active === 3 && (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Personal Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="First Name" value={`${firstName.en}${firstName.am ? ` / ${firstName.am}` : ''}`} />
<ReviewRow label="Middle Name" value={`${middleName.en}${middleName.am ? ` / ${middleName.am}` : ''}`} />
<ReviewRow label="Last Name" value={`${lastName.en}${lastName.am ? ` / ${lastName.am}` : ''}`} />
<ReviewRow label="Gender" value={gender ?? ''} />
<ReviewRow label="Date of Birth" value={`${dob?.toLocaleDateString('en-US') ?? ''}${dob ? ` (${toEthiopicDateLabel(dob)})` : ''}`} />
<ReviewRow label="Place of Birth" value={placeOfBirth} />
<ReviewRow label="Nationality" value={nationality ?? ''} />
<ReviewRow label="Marital Status" value={maritalStatus ?? ''} />
<ReviewRow label="National ID No." value={nationalIdNumber} />
<ReviewRow label="Passport No." value={passportNumber} />
<ReviewRow label="Passport Expiry" value={passportExpiry} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Contact Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Mobile" value={mobile} />
<ReviewRow label="Email" value={email} />
<ReviewRow label="Location" value={locationId ?? ''} />
<ReviewRow label="Permanent Address" value={permanentAddress} />
<ReviewRow label="Current Address" value={currentAddress} />
</SimpleGrid>
{emergencyName && (
<>
<Divider mt="md" mb="sm" />
<Text fw={600} fz="sm" mb="sm">Emergency Contact</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Name" value={emergencyName} />
<ReviewRow label="Relationship" value={emergencyRel ?? ''} />
<ReviewRow label="Phone" value={emergencyPhone} />
</SimpleGrid>
</>
)}
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap="xs" align="center">
{files[slot.key] ? (
<IconCircleCheck size={18} color="var(--mantine-color-teal-6)" />
) : (
<Box style={{ width: 18, height: 18, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)', flexShrink: 0 }} />
)}
<div>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label}
{slot.required && !files[slot.key] && <Text span c="red" ml={3}>*</Text>}
</Text>
{files[slot.key] && (
<Text fz="xs" c="dimmed" truncate maw={160}>{files[slot.key]!.name}</Text>
)}
</div>
</Group>
))}
</SimpleGrid>
</Paper>
</Stack>
)}
{active === 3 && review}
{/* Navigation buttons */}
<Group justify="space-between" mt="xl">

View File

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