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, Text,
ThemeIcon, ThemeIcon,
Title, Title,
Tooltip,
rem, rem,
} from '@mantine/core'; } from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
@@ -29,8 +30,12 @@ import {
IconInfoCircle, IconInfoCircle,
IconShieldCheck, IconShieldCheck,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { authStorage } from '@ema-platform/auth'; import { authStorage, useCurrentProfile } from '@ema-platform/auth';
import { useApiQuery } from '@ema-platform/api'; import { useApiQuery } from '@ema-platform/api';
import {
useGetMySeaServiceRecordsQuery,
useGetMyMedicalCertificatesQuery,
} from '@ema-platform/api';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock data // Mock data
@@ -143,6 +148,25 @@ export function CertificatesPage() {
const certificates = data?.certificates ?? []; const certificates = data?.certificates ?? [];
const applications = data?.applications ?? []; 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) => { const openPreview = async (profileId: string, title: string) => {
setLoading(true); setLoading(true);
try { try {
@@ -186,15 +210,35 @@ export function CertificatesPage() {
<Title order={3}>Certificates (CoC / CoP)</Title> <Title order={3}>Certificates (CoC / CoP)</Title>
<Text fz="sm" c="dimmed">Certificate of Competency and Certificate of Proficiency under STCW</Text> <Text fz="sm" c="dimmed">Certificate of Competency and Certificate of Proficiency under STCW</Text>
</div> </div>
<Button <Tooltip
leftSection={<IconShieldCheck size={15} />} label={missingReasons.join(' ')}
rightSection={<IconArrowRight size={15} />} disabled={canApply}
onClick={() => navigate('/certificates/apply')} multiline
w={260}
events={{ hover: true, focus: true, touch: true }}
> >
Apply for CoC / CoP {/* Tooltip needs a hoverable child even while the button itself is
</Button> 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> </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 */} {/* Info banner */}
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)"> <Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="md" wrap="nowrap"> <Group gap="md" wrap="nowrap">

View File

@@ -311,8 +311,19 @@ export function SeafarerRegistrationPage() {
setEmail((c) => c || profile?.address?.email || user?.email || ''); setEmail((c) => c || profile?.address?.email || user?.email || '');
setMobile((c) => c || profile?.address?.primaryPhoneNumber || user?.phoneNumber || ''); setMobile((c) => c || profile?.address?.primaryPhoneNumber || user?.phoneNumber || '');
if (profile?.address?.idNumber) setNationalIdNumber((c) => c || profile.address.idNumber); 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]); }, [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 = () => { const canNext = () => {
if (active === 0) return !!firstName.en.trim() && !!lastName.en.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim(); 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; if (active === 1) return !!mobile.trim() && !!email.trim() && !!locationId;
@@ -361,7 +372,9 @@ export function SeafarerRegistrationPage() {
}).unwrap(); }).unwrap();
notify.success(`Registration submitted! Profile ID: ${profileId.slice(0, 8).toUpperCase()}`); notify.success(`Registration submitted! Profile ID: ${profileId.slice(0, 8).toUpperCase()}`);
navigate('/seafarer-registry'); setEditing(false);
setActive(0);
setCompleted([]);
} catch { } catch {
notify.error('Submission failed. Please try again.'); notify.error('Submission failed. Please try again.');
} finally { } 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 stepLabel = STEPS[active]?.label ?? '';
const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck]; const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck];
const StepIcon = stepIcons[active]; 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 ( return (
<Stack gap="md"> <Stack gap="md">
{/* Page header */} {/* Page header */}
@@ -498,72 +603,7 @@ export function SeafarerRegistrationPage() {
)} )}
{/* ── Step 4: Review & Submit ─────────────────────────────────── */} {/* ── Step 4: Review & Submit ─────────────────────────────────── */}
{active === 3 && ( {active === 3 && 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>
)}
{/* Navigation buttons */} {/* Navigation buttons */}
<Group justify="space-between" mt="xl"> <Group justify="space-between" mt="xl">

View File

@@ -91,6 +91,13 @@ export interface ProfileMeResponse {
* `usePermissions()`. * `usePermissions()`.
*/ */
permissions?: string[]; 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 const profileApi = baseApi
@@ -167,6 +174,7 @@ export function useCurrentProfile() {
refetch, refetch,
completeness: data?.completeness ?? 0, completeness: data?.completeness ?? 0,
missing, missing,
eligibleForCoc: data?.eligibleForCoc ?? false,
/** /**
* True when nothing the requirement asks for is still blank. Unknown * True when nothing the requirement asks for is still blank. Unknown
* profile (still loading) reads as not-ready, so a caller never submits * profile (still loading) reads as not-ready, so a caller never submits