mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat(certificates): enhance eligibility checks for CoC/CoP application with tooltips and alerts
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user