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,
|
||||
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>
|
||||
<Tooltip
|
||||
label={missingReasons.join(' ')}
|
||||
disabled={canApply}
|
||||
multiline
|
||||
w={260}
|
||||
events={{ hover: true, focus: true, touch: true }}
|
||||
>
|
||||
{/* 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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user