mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: Refactor seafarer document application flow
- Remove SeamanBookApplicationPage from router and redirect to Seaman Book page. - Add new API endpoints for managing seafarer documents, including listing, reviewing, and issuing documents. - Introduce new SeafarerDocumentQueuePage and SeafarerDocumentReviewPage components for document management. - Update licensing types to accommodate optional applicationId and documentId in ApplicationPayment. - Remove unused claimSeafarerRegistration mutation and related constants. - Update seafarer registration status labels and types to remove 'UNDER_REVIEW'. - Create new constants and types for seafarer documents, including status labels and colors. - Implement document payment initiation and confirmation functionalities. - Enhance UI components for better user experience in document management.
This commit is contained in:
@@ -2,7 +2,9 @@ import { useState } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useInitiateDocumentPaymentMutation,
|
||||
useInitiatePaymentMutation,
|
||||
type InitiatePaymentResult,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
@@ -15,21 +17,31 @@ import {
|
||||
*/
|
||||
export function useApplicationPayment() {
|
||||
const [initiate, { isLoading }] = useInitiatePaymentMutation();
|
||||
const [initiateDocument, { isLoading: isLoadingDocument }] =
|
||||
useInitiateDocumentPaymentMutation();
|
||||
const [redirecting, setRedirecting] = useState(false);
|
||||
|
||||
async function pay(
|
||||
applicationId: string,
|
||||
provider = 'TELEBIRR',
|
||||
): Promise<void> {
|
||||
const platform = () =>
|
||||
// Deep links only work inside a mobile browser; assume web otherwise.
|
||||
/Android|iPhone|iPad/i.test(navigator.userAgent) ? 'mobile' : 'web';
|
||||
|
||||
/** A licence application's fee. */
|
||||
function pay(applicationId: string, provider = 'TELEBIRR'): Promise<void> {
|
||||
return handOver(() =>
|
||||
initiate({ id: applicationId, provider, platform: platform() }).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
/** A Seaman Book / BTC fee — same gateway, its own endpoint. */
|
||||
function payDocument(documentId: string, provider = 'TELEBIRR'): Promise<void> {
|
||||
return handOver(() =>
|
||||
initiateDocument({ id: documentId, provider, platform: platform() }).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
async function handOver(start: () => Promise<InitiatePaymentResult>): Promise<void> {
|
||||
try {
|
||||
const result = await initiate({
|
||||
id: applicationId,
|
||||
provider,
|
||||
// Deep links only work inside a mobile browser; assume web otherwise.
|
||||
platform: /Android|iPhone|iPad/i.test(navigator.userAgent)
|
||||
? 'mobile'
|
||||
: 'web',
|
||||
}).unwrap();
|
||||
const result = await start();
|
||||
|
||||
const action = result.clientAction;
|
||||
|
||||
@@ -66,5 +78,5 @@ export function useApplicationPayment() {
|
||||
}
|
||||
}
|
||||
|
||||
return { pay, isPaying: isLoading || redirecting };
|
||||
return { pay, payDocument, isPaying: isLoading || isLoadingDocument || redirecting };
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
IconClockHour4,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||
import { useGetApplicationPaymentQuery, useGetDocumentPaymentQuery } from '@ema-platform/api';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const MAX_ATTEMPTS = 10;
|
||||
@@ -35,33 +35,39 @@ export function PaymentCheckPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
// Seaman Book / BTC fees come back with `documentId` instead.
|
||||
const documentId = params.get('documentId') ?? '';
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
|
||||
const { data, refetch, isLoading } = useGetApplicationPaymentQuery(
|
||||
applicationId,
|
||||
{ skip: !applicationId },
|
||||
);
|
||||
const applicationPayment = useGetApplicationPaymentQuery(applicationId, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
const documentPayment = useGetDocumentPaymentQuery(documentId, { skip: !documentId });
|
||||
const { data, refetch, isLoading } = documentId ? documentPayment : applicationPayment;
|
||||
const subject = documentId ? `documentId=${documentId}` : `applicationId=${applicationId}`;
|
||||
const hasSubject = Boolean(applicationId || documentId);
|
||||
const backTo = documentId ? '/seaman-book' : '/licensing/applications';
|
||||
|
||||
const status = data?.status ?? null;
|
||||
const settled = status === 'PAID' || status === 'FAILED' || status === 'CANCELLED';
|
||||
|
||||
useEffect(() => {
|
||||
if (!applicationId || settled || attempts >= MAX_ATTEMPTS) return;
|
||||
if (!hasSubject || settled || attempts >= MAX_ATTEMPTS) return;
|
||||
const timer = setTimeout(() => {
|
||||
refetch();
|
||||
setAttempts((n) => n + 1);
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [applicationId, settled, attempts, refetch]);
|
||||
}, [hasSubject, settled, attempts, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'PAID') navigate(`/payments/success?applicationId=${applicationId}`);
|
||||
if (status === 'PAID') navigate(`/payments/success?${subject}`);
|
||||
if (status === 'FAILED' || status === 'CANCELLED') {
|
||||
navigate(`/payments/failure?applicationId=${applicationId}`);
|
||||
navigate(`/payments/failure?${subject}`);
|
||||
}
|
||||
}, [status, applicationId, navigate]);
|
||||
}, [status, subject, navigate]);
|
||||
|
||||
if (!applicationId) {
|
||||
if (!hasSubject) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Card withBorder padding="xl">
|
||||
@@ -73,7 +79,7 @@ export function PaymentCheckPage() {
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('payments.check.notFoundBody')}
|
||||
</Text>
|
||||
<Button onClick={() => navigate('/licensing/applications')}>
|
||||
<Button onClick={() => navigate(backTo)}>
|
||||
{t('payments.myApplications')}
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -101,7 +107,7 @@ export function PaymentCheckPage() {
|
||||
<Button variant="default" onClick={() => { setAttempts(0); refetch(); }}>
|
||||
{t('payments.check.checkAgain')}
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/licensing/applications')}>
|
||||
<Button onClick={() => navigate(backTo)}>
|
||||
{t('payments.myApplications')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||
import { useGetApplicationPaymentQuery, useGetDocumentPaymentQuery } from '@ema-platform/api';
|
||||
|
||||
/** Shown when Telebirr reported the payment as failed or cancelled. */
|
||||
export function PaymentFailurePage() {
|
||||
@@ -19,9 +19,14 @@ export function PaymentFailurePage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
const { data } = useGetApplicationPaymentQuery(applicationId, {
|
||||
// Seaman Book / BTC fees come back with `documentId` instead.
|
||||
const documentId = params.get('documentId') ?? '';
|
||||
const applicationPayment = useGetApplicationPaymentQuery(applicationId, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
const documentPayment = useGetDocumentPaymentQuery(documentId, { skip: !documentId });
|
||||
const { data } = documentId ? documentPayment : applicationPayment;
|
||||
const backTo = documentId ? '/seaman-book' : '/licensing/applications';
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
@@ -38,7 +43,7 @@ export function PaymentFailurePage() {
|
||||
{t('payments.failure.unchanged')}
|
||||
</Text>
|
||||
<Group mt="md">
|
||||
<Button variant="default" onClick={() => navigate('/licensing/applications')}>
|
||||
<Button variant="default" onClick={() => navigate(backTo)}>
|
||||
{t('payments.myApplications')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconCircleCheck } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||
import { useGetApplicationPaymentQuery, useGetDocumentPaymentQuery } from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/** Confirmation that the licence fee has been received. */
|
||||
@@ -22,9 +22,14 @@ export function PaymentSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
const { data } = useGetApplicationPaymentQuery(applicationId, {
|
||||
// Seaman Book / BTC fees come back with `documentId` instead.
|
||||
const documentId = params.get('documentId') ?? '';
|
||||
const applicationPayment = useGetApplicationPaymentQuery(applicationId, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
const documentPayment = useGetDocumentPaymentQuery(documentId, { skip: !documentId });
|
||||
const { data } = documentId ? documentPayment : applicationPayment;
|
||||
const backTo = documentId ? '/seaman-book' : '/licensing/applications';
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
@@ -68,7 +73,7 @@ export function PaymentSuccessPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button mt="md" onClick={() => navigate('/licensing/applications')}>
|
||||
<Button mt="md" onClick={() => navigate(backTo)}>
|
||||
{t('payments.success.backToApplications')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -187,8 +187,7 @@ export function SeafarerRegistrationPage() {
|
||||
}
|
||||
|
||||
const isAdjusting = registration.status === 'RESUBMIT_REQUIRED';
|
||||
const editableWhileSubmitted = registration.status === 'SUBMITTED' && !registration.assignedOfficerId;
|
||||
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(registration.status) && !editableWhileSubmitted;
|
||||
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(registration.status);
|
||||
const showSummary = registration.status !== 'DRAFT' && viewingSummary;
|
||||
|
||||
function set(key: AnswerKey, value: unknown) {
|
||||
@@ -336,10 +335,10 @@ export function SeafarerRegistrationPage() {
|
||||
{registration.reviewRemark}
|
||||
</Alert>
|
||||
)}
|
||||
{showSummary && editableWhileSubmitted && (
|
||||
<Alert color="blue" icon={<IconInfoCircle size={16} />} title="Submitted — still correctable" mb="md">
|
||||
Your registration is in the queue. You can still change any detail until a reviewing officer
|
||||
picks it up; after that, corrections happen only if they ask for them.
|
||||
{registration.status === 'SUBMITTED' && (
|
||||
<Alert color="blue" icon={<IconInfoCircle size={16} />} title="Submitted" mb="md">
|
||||
Your registration is with the Authority for review. You will be notified of the outcome, or
|
||||
asked for corrections if anything is missing.
|
||||
</Alert>
|
||||
)}
|
||||
{issues.length > 0 && (
|
||||
|
||||
@@ -1,595 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCreditCard,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steps
|
||||
// ---------------------------------------------------------------------------
|
||||
const STEPS = [
|
||||
{ label: 'Relevant Certificate' },
|
||||
{ label: 'Medical Certificate' },
|
||||
{ label: 'Payment' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fee table — Seaman Book + BTC shown separately, paid together
|
||||
// ---------------------------------------------------------------------------
|
||||
const FEES = [
|
||||
{ label: 'Seaman Book — Application Fee', amount: 500 },
|
||||
{ label: 'Seaman Book — Document Verification Fee',amount: 200 },
|
||||
{ label: 'Basic Training Certificate (BTC) — Application Fee', amount: 300 },
|
||||
{ label: 'BTC — Document Verification Fee', amount: 100 },
|
||||
{ label: 'BSID — Application Fee', amount: 100 },
|
||||
{ label: 'BSID — Card Production Fee', amount: 150 },
|
||||
];
|
||||
const TOTAL = FEES.reduce((s, f) => s + f.amount, 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap">
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box style={{
|
||||
width: rem(40), height: rem(40), borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : isCurrent ? 'var(--mantine-color-blue-7)' : 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34,139,230,0.2)' : 'none',
|
||||
flexShrink: 0, transition: 'all 0.2s ease',
|
||||
}}>
|
||||
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box style={{
|
||||
flex: 1, height: rem(2),
|
||||
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeamanBookApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Relevant Certificate
|
||||
const [relCertNumber, setRelCertNumber] = useState('');
|
||||
const [relIssuer, setRelIssuer] = useState('');
|
||||
const [relIssueDate, setRelIssueDate] = useState('');
|
||||
const [relExpiryDate, setRelExpiryDate] = useState('');
|
||||
const [relFile, setRelFile] = useState<File | null>(null);
|
||||
const relResetRef = useRef<() => void>(null);
|
||||
|
||||
// Medical
|
||||
const [medCertNumber, setMedCertNumber] = useState('');
|
||||
const [medIssuer, setMedIssuer] = useState('');
|
||||
const [medIssueDate, setMedIssueDate] = useState('');
|
||||
const [medExpiryDate, setMedExpiryDate] = useState('');
|
||||
const [medFile, setMedFile] = useState<File | null>(null);
|
||||
const medResetRef = useRef<() => void>(null);
|
||||
|
||||
// Payment
|
||||
const [paymentMethod, setPaymentMethod] = useState<'cbe' | 'telebirr' | null>(null);
|
||||
const [paymentRef, setPaymentRef] = useState('');
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [paymentFile, setPaymentFile] = useState<File | null>(null);
|
||||
const payResetRef = useRef<() => void>(null);
|
||||
|
||||
// Validation
|
||||
const relComplete = !!relFile && !!relCertNumber.trim() && !!relIssuer.trim() && !!relIssueDate && !!relExpiryDate;
|
||||
const medComplete = !!medFile && !!medCertNumber.trim() && !!medIssuer.trim() && !!medIssueDate && !!medExpiryDate;
|
||||
const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate;
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return relComplete;
|
||||
if (active === 1) return medComplete;
|
||||
if (active === 2) return payComplete;
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 1400));
|
||||
notify.success('Application submitted! Reference: SB-BTC-2025-001');
|
||||
navigate('/seaman-book');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Seaman Book, BTC & BSID Application</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
One application covers your Seaman Book, Basic Training Certificate (BTC), and BSID — Step {active + 1} of {STEPS.length}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* What you will receive banner */}
|
||||
<Paper withBorder radius="md" p="md" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-2)' }}>
|
||||
<Group gap="lg" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
<IconBook2 size={18} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
<Text fz="sm" fw={600} c="blue.8">Seaman Book</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">+</Text>
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" stroke={1.5} />
|
||||
<Text fz="sm" fw={600} c="teal.8">Basic Training Certificate (BTC)</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">+</Text>
|
||||
<Group gap="xs">
|
||||
<IconId size={18} color="var(--mantine-color-violet-6)" stroke={1.5} />
|
||||
<Text fz="sm" fw={600} c="violet.8">BSID (Biometric Seafarer ID)</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="lg">{STEPS[active]?.label}</Text>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* ── Step 1: Relevant Certificate ────────────────────────────── */}
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
Upload your relevant certificate issued by an EMA-approved training institution. This is the prerequisite for your Basic Training Certificate (BTC).
|
||||
</Alert>
|
||||
<SectionHead title="Relevant Certificate Details" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Certificate Number"
|
||||
placeholder="e.g. CERT-2024-001"
|
||||
required
|
||||
value={relCertNumber}
|
||||
onChange={(e) => setRelCertNumber(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Issuing Institution"
|
||||
placeholder="e.g. Bahirdar Maritime School"
|
||||
required
|
||||
value={relIssuer}
|
||||
onChange={(e) => setRelIssuer(e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Issue Date"
|
||||
type="date"
|
||||
required
|
||||
value={relIssueDate}
|
||||
onChange={(e) => setRelIssueDate(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Expiry Date"
|
||||
type="date"
|
||||
required
|
||||
value={relExpiryDate}
|
||||
onChange={(e) => setRelExpiryDate(e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Upload Certificate" />
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderStyle: relFile ? 'solid' : 'dashed',
|
||||
borderColor: relFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
maxWidth: rem(420),
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(40), height: rem(40), borderRadius: rem(8),
|
||||
background: relFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<IconShieldCheck size={20} color={relFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">Relevant Certificate <Text span c="red">*</Text></Text>
|
||||
<Text fz="xs" c="dimmed">PDF, JPG or PNG — max 5MB</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{relFile ? (
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{relFile.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { setRelFile(null); relResetRef.current?.(); }}>
|
||||
<IconTrash size={13} />
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={relResetRef} onChange={setRelFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
|
||||
Choose File
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Medical ─────────────────────────────────────────── */}
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
Upload your valid medical certificate from an EMA-approved medical centre. Required for both Seaman Book and BTC issuance.
|
||||
</Alert>
|
||||
<SectionHead title="Medical Certificate Details" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Certificate Number" placeholder="e.g. MC-2024-001" required value={medCertNumber} onChange={(e) => setMedCertNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Issuing Medical Centre" placeholder="e.g. EMA Medical Centre" required value={medIssuer} onChange={(e) => setMedIssuer(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Issue Date" type="date" required value={medIssueDate} onChange={(e) => setMedIssueDate(e.currentTarget.value)} />
|
||||
<TextInput label="Expiry Date" type="date" required value={medExpiryDate} onChange={(e) => setMedExpiryDate(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Upload Certificate" />
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: medFile ? 'solid' : 'dashed',
|
||||
borderColor: medFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
maxWidth: rem(420),
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(40), height: rem(40), borderRadius: rem(8),
|
||||
background: medFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<IconHeart size={20} color={medFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">Medical Certificate <Text span c="red">*</Text></Text>
|
||||
<Text fz="xs" c="dimmed">PDF, JPG or PNG — max 5MB</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{medFile ? (
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{medFile.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { setMedFile(null); medResetRef.current?.(); }}>
|
||||
<IconTrash size={13} />
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={medResetRef} onChange={setMedFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
|
||||
Choose File
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
|
||||
Only certificates from <strong>EMA-approved medical centres</strong> are accepted.
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Payment ─────────────────────────────────────────── */}
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
{/* Fee breakdown — SB + BTC shown separately */}
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0">
|
||||
<Text fw={700} fz="sm" mb="xs">Fee Breakdown</Text>
|
||||
<Text fz="xs" c="dimmed" mb="md">Your payment covers both the Seaman Book and Basic Training Certificate (BTC).</Text>
|
||||
|
||||
{/* SB fees */}
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="blue.7" mb={6}>Seaman Book</Text>
|
||||
{FEES.filter(f => f.label.startsWith('Seaman Book')).map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label.replace('Seaman Book — ', '')}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
{/* BTC fees */}
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="teal.7" mb={6}>Basic Training Certificate (BTC)</Text>
|
||||
{FEES.filter(f => f.label.startsWith('Basic Training') || f.label.startsWith('BTC')).map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label.replace('Basic Training Certificate (BTC) — ', '').replace('BTC — ', '')}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
{/* BSID fees */}
|
||||
<Divider my="xs" />
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="violet.7" mb={6}>BSID (Biometric Seafarer ID)</Text>
|
||||
{FEES.filter(f => f.label.startsWith('BSID')).map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label.replace('BSID — ', '')}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
<Divider mt="xs" mb="sm" />
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={800}>Total Amount Due</Text>
|
||||
<Text fz="md" fw={800} c="blue">ETB {TOTAL.toFixed(2)}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SectionHead title="Select Payment Method" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" style={{ maxWidth: rem(600) }}>
|
||||
{/* CBE */}
|
||||
<Card withBorder radius="md" p="md" onClick={() => { setPaymentMethod('cbe'); setPaymentRef(''); }}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: paymentMethod === 'cbe' ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-default-border)',
|
||||
borderWidth: paymentMethod === 'cbe' ? 2 : 1,
|
||||
background: paymentMethod === 'cbe' ? 'var(--mantine-color-blue-light)' : undefined,
|
||||
}}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8), flexShrink: 0,
|
||||
background: 'var(--mantine-color-blue-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<IconCreditCard size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">CBE Bank Transfer</Text>
|
||||
<Text fz="xs" c="dimmed">Commercial Bank of Ethiopia</Text>
|
||||
</div>
|
||||
{paymentMethod === 'cbe' && <IconCircleCheck size={18} color="var(--mantine-color-blue-6)" style={{ marginLeft: 'auto' }} />}
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* Telebirr */}
|
||||
<Card withBorder radius="md" p="md" onClick={() => { setPaymentMethod('telebirr'); setPaymentRef(''); }}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: paymentMethod === 'telebirr' ? 'var(--mantine-color-violet-6)' : 'var(--mantine-color-default-border)',
|
||||
borderWidth: paymentMethod === 'telebirr' ? 2 : 1,
|
||||
background: paymentMethod === 'telebirr' ? 'var(--mantine-color-violet-light)' : undefined,
|
||||
}}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8), flexShrink: 0,
|
||||
background: 'var(--mantine-color-violet-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<IconCreditCard size={22} color="var(--mantine-color-violet-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">Telebirr</Text>
|
||||
<Text fz="xs" c="dimmed">Ethio Telecom Mobile Money</Text>
|
||||
</div>
|
||||
{paymentMethod === 'telebirr' && <IconCircleCheck size={18} color="var(--mantine-color-violet-6)" style={{ marginLeft: 'auto' }} />}
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{paymentMethod === 'cbe' && (
|
||||
<>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
Transfer <strong>ETB {TOTAL.toFixed(2)}</strong> to CBE Account <strong>1000123456789</strong> (EMA Maritime Authority). Use your full name as description.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="CBE Transaction Reference" placeholder="e.g. CBE-TXN-20240510-001" required value={paymentRef} onChange={(e) => setPaymentRef(e.currentTarget.value)} />
|
||||
<TextInput label="Payment Date" type="date" required value={paymentDate} onChange={(e) => setPaymentDate(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'telebirr' && (
|
||||
<>
|
||||
<Alert variant="light" color="violet" icon={<IconInfoCircle size={15} />}>
|
||||
Send <strong>ETB {TOTAL.toFixed(2)}</strong> to Telebirr <strong>+251 11 551 0000</strong> (EMA Maritime Authority). Screenshot and upload your confirmation.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="Telebirr Transaction ID" placeholder="e.g. TLB-2024-001234" required value={paymentRef} onChange={(e) => setPaymentRef(e.currentTarget.value)} />
|
||||
<TextInput label="Payment Date" type="date" required value={paymentDate} onChange={(e) => setPaymentDate(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{paymentMethod && (
|
||||
<>
|
||||
<SectionHead title="Upload Receipt (optional)" />
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: paymentFile ? 'solid' : 'dashed',
|
||||
borderColor: paymentFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
maxWidth: rem(420),
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(40), height: rem(40), borderRadius: rem(8),
|
||||
background: paymentFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<IconCreditCard size={20} color={paymentFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{paymentMethod === 'telebirr' ? 'Telebirr Screenshot' : 'Bank Receipt'}</Text>
|
||||
<Text fz="xs" c="dimmed">PDF, JPG or PNG — max 5MB</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{paymentFile ? (
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{paymentFile.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { setPaymentFile(null); payResetRef.current?.(); }}>
|
||||
<IconTrash size={13} />
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={payResetRef} onChange={setPaymentFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
|
||||
Upload Receipt
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Review ──────────────────────────────────────────── */}
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
Submitting this application will initiate processing for both your <strong>Seaman Book</strong> and <strong>Basic Training Certificate (BTC)</strong>.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Relevant Certificate</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Certificate No." value={relCertNumber} />
|
||||
<ReviewRow label="Issuing Institution" value={relIssuer} />
|
||||
<ReviewRow label="Issue Date" value={relIssueDate} />
|
||||
<ReviewRow label="Expiry Date" value={relExpiryDate} />
|
||||
<ReviewRow label="Document" value={relFile?.name ?? '—'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Medical Certificate</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Certificate No." value={medCertNumber} />
|
||||
<ReviewRow label="Issuing Centre" value={medIssuer} />
|
||||
<ReviewRow label="Issue Date" value={medIssueDate} />
|
||||
<ReviewRow label="Expiry Date" value={medExpiryDate} />
|
||||
<ReviewRow label="Document" value={medFile?.name ?? '—'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Payment</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Payment Method" value={paymentMethod === 'cbe' ? 'CBE Bank Transfer' : 'Telebirr'} />
|
||||
<ReviewRow label="Transaction Reference" value={paymentRef} />
|
||||
<ReviewRow label="Payment Date" value={paymentDate} />
|
||||
<ReviewRow label="Total Paid" value={`ETB ${TOTAL.toFixed(2)}`} />
|
||||
<ReviewRow label="Receipt" value={paymentFile?.name ?? 'Not uploaded'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" onClick={() => navigate('/seaman-book')}>Cancel</Button>
|
||||
<Group gap="sm">
|
||||
{active > 0 && (
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={prev}>Previous</Button>
|
||||
)}
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button rightSection={<IconArrowRight size={16} />} onClick={next} disabled={!canNext()}>
|
||||
Next Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="blue" leftSection={<IconBook2 size={16} />} onClick={handleSubmit} loading={submitting}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,11 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
useApiQuery,
|
||||
useBypassPaymentMutation,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
} from "@ema-platform/api";
|
||||
import { useApplicationPayment } from "../../payments/hooks/useApplicationPayment";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -22,111 +15,50 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconInfoCircle,
|
||||
IconPrinter,
|
||||
IconShield,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
interface ApplicationSummary {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
status: string;
|
||||
submittedAt: string;
|
||||
/** Set once an officer schedules the pickup date, ahead of CERTIFICATE_ISSUED. */
|
||||
scheduledIssuanceDate: string | null;
|
||||
}
|
||||
|
||||
/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
|
||||
interface SeamanBookOverview {
|
||||
application: ApplicationSummary | null;
|
||||
/**
|
||||
* The Basic Training Certificate opened alongside the book by an approved
|
||||
* seafarer registration — a separate application, separately numbered and
|
||||
* separately billed, so it is shown as its own card rather than merged in.
|
||||
*/
|
||||
btcApplication: ApplicationSummary | null;
|
||||
book: {
|
||||
id: string;
|
||||
issuedDate: string;
|
||||
expiryDate: string;
|
||||
status: string;
|
||||
} | null;
|
||||
eligibility: {
|
||||
hasProfile: boolean;
|
||||
hasSeafarerNumber: boolean;
|
||||
hasMedical: boolean;
|
||||
medicalExpiry: string | null;
|
||||
bstComplete: boolean;
|
||||
bstModules: { key: string; label: string; done: boolean }[];
|
||||
};
|
||||
eligible: boolean;
|
||||
}
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
useBypassDocumentPaymentMutation,
|
||||
useGetMySeafarerDocumentsQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useLazyGetMySeafarerDocumentDownloadQuery,
|
||||
type SeafarerDocument,
|
||||
type SeafarerDocumentStatus,
|
||||
} from "@ema-platform/api";
|
||||
import { useApplicationPayment } from "../../payments/hooks/useApplicationPayment";
|
||||
|
||||
/**
|
||||
* The stages an application passes through, for the progress stepper.
|
||||
*
|
||||
* Derived from the application's status rather than stored as a timeline:
|
||||
* the status is what the workflow actually moves, so a second record of the
|
||||
* same journey would only drift out of step with it.
|
||||
* The stages a document passes through, for the progress stepper. Derived
|
||||
* from the status the API moves, never stored separately.
|
||||
*/
|
||||
const STAGES: { label: string; statuses: string[] }[] = [
|
||||
{
|
||||
label: "Submitted",
|
||||
statuses: ["SUBMITTED", "UNDER_REVIEW", "UNDER_EVALUATION"],
|
||||
},
|
||||
{ label: "Under Review", statuses: ["UNDER_REVIEW", "UNDER_EVALUATION"] },
|
||||
{
|
||||
label: "Approved",
|
||||
statuses: ["APPROVED", "PAYMENT_PENDING", "PAID", "PAYMENT_CONFIRMED"],
|
||||
},
|
||||
// Printed once, handed over in person — an officer sets a pickup date
|
||||
// before this reaches CERTIFICATE_ISSUED.
|
||||
const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [
|
||||
{ label: "Requested", statuses: ["AWAITING_REGISTRATION"] },
|
||||
{ label: "Payment", statuses: ["PAYMENT_PENDING"] },
|
||||
{ label: "Paid", statuses: ["PAID", "PAYMENT_CONFIRMED"] },
|
||||
{ label: "Pickup Scheduled", statuses: ["SCHEDULED"] },
|
||||
{ label: "Issued", statuses: ["CERTIFICATE_ISSUED", "COMPLETED"] },
|
||||
{ label: "Issued", statuses: ["ISSUED"] },
|
||||
];
|
||||
|
||||
/** How far along the stepper a status sits; -1 for a draft. */
|
||||
function stageIndexFor(status: string | undefined): number {
|
||||
if (!status || status === "DRAFT") return -1;
|
||||
function stageIndexFor(status: SeafarerDocumentStatus): number {
|
||||
let reached = -1;
|
||||
STAGES.forEach((stage, i) => {
|
||||
if (stage.statuses.includes(status)) reached = i;
|
||||
});
|
||||
// A status past the last named stage (e.g. REJECTED) still shows the
|
||||
// journey taken rather than collapsing the stepper to nothing.
|
||||
return reached;
|
||||
}
|
||||
|
||||
// Keyed by the workflow's own status values, not display strings: the badge
|
||||
// reads whatever the API reports, and an unmapped status falls back to grey
|
||||
// rather than vanishing.
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
DRAFT: "gray",
|
||||
SUBMITTED: "blue",
|
||||
UNDER_REVIEW: "yellow",
|
||||
UNDER_EVALUATION: "yellow",
|
||||
RESUBMIT_REQUIRED: "orange",
|
||||
INSPECTION_PENDING: "grape",
|
||||
INSPECTION_COMPLETED: "grape",
|
||||
APPROVED: "teal",
|
||||
REJECTED: "red",
|
||||
ON_HOLD: "orange",
|
||||
PAYMENT_PENDING: "orange",
|
||||
PAID: "blue",
|
||||
PAYMENT_CONFIRMED: "blue",
|
||||
SCHEDULED: "grape",
|
||||
CERTIFICATE_ISSUED: "teal",
|
||||
COMPLETED: "teal",
|
||||
};
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
@@ -135,42 +67,23 @@ function formatDate(value: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon
|
||||
size={22}
|
||||
radius="xl"
|
||||
variant={ok ? "filled" : "light"}
|
||||
color={ok ? "teal" : "red"}
|
||||
>
|
||||
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={ok ? undefined : "dimmed"}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
/** One document: where it stands, what the applicant can do about it now. */
|
||||
function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onChanged: () => void }) {
|
||||
const { payDocument, isPaying } = useApplicationPayment();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const [bypass, { isLoading: bypassing }] = useBypassDocumentPaymentMutation();
|
||||
const [getDownload, { isFetching: downloading }] = useLazyGetMySeafarerDocumentDownloadQuery();
|
||||
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
||||
const activeStep = stageIndexFor(document.status);
|
||||
|
||||
/**
|
||||
* One in-flight application: its number, where it stands, and the stages left.
|
||||
*
|
||||
* Shared by the Seaman Book and the BTC because an approved registration opens
|
||||
* both and they move independently — the book waits on a TRB inspection while
|
||||
* the BTC goes straight to payment, so a single merged card would have to lie
|
||||
* about one of them.
|
||||
*/
|
||||
function ApplicationCard({
|
||||
title,
|
||||
application,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
application: ApplicationSummary;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const activeStep = stageIndexFor(application.status);
|
||||
async function download() {
|
||||
try {
|
||||
const { url } = await getDownload(document.id).unwrap();
|
||||
window.open(url, "_blank", "noopener");
|
||||
} catch (err) {
|
||||
notifications.show({ color: "red", title: "Download failed", message: extractErrorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
@@ -181,111 +94,113 @@ function ApplicationCard({
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>
|
||||
{title} — {application.id}
|
||||
{title} — {document.documentNumber ?? document.requestNumber}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{/* An approved seafarer registration opens this application as a
|
||||
draft, so it can be here before anyone has filed it. Calling
|
||||
that "Submitted" would misreport where it stands. */}
|
||||
{application.status === "DRAFT" ? "Opened" : "Submitted"}{" "}
|
||||
{formatDate(application.submittedAt)}
|
||||
Requested {formatDate(document.createdAt)}
|
||||
{document.feeAmount !== null && ` · Fee ${document.feeAmount} ${document.feeCurrency}`}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge
|
||||
color={STATUS_COLOR[application.status] ?? "gray"}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
{application.status.replaceAll("_", " ")}
|
||||
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
|
||||
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{STAGES.map((stage, i) => (
|
||||
<Stepper.Step
|
||||
key={stage.label}
|
||||
label={stage.label}
|
||||
description={i <= activeStep ? "Done" : "Pending"}
|
||||
icon={
|
||||
i <= activeStep ? (
|
||||
<IconCircleCheck size={16} />
|
||||
) : (
|
||||
<IconClock size={16} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stepper>
|
||||
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{STAGES.map((stage, i) => (
|
||||
<Stepper.Step
|
||||
key={stage.label}
|
||||
label={stage.label}
|
||||
description={i <= activeStep ? "Done" : "Pending"}
|
||||
icon={i <= activeStep ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
|
||||
/>
|
||||
))}
|
||||
</Stepper>
|
||||
)}
|
||||
|
||||
{children}
|
||||
{document.status === "AWAITING_REGISTRATION" && (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={17} />} mt="md">
|
||||
Requested with your seafarer registration. It moves to payment as soon as the
|
||||
registration is approved.
|
||||
</Alert>
|
||||
)}
|
||||
{document.status === "PAYMENT_PENDING" && (
|
||||
<Group mt="md">
|
||||
<Button loading={isPaying} onClick={() => payDocument(document.id)}>
|
||||
Pay now
|
||||
</Button>
|
||||
{capabilities?.bypassEnabled && (
|
||||
<Button
|
||||
variant="default"
|
||||
loading={bypassing}
|
||||
onClick={async () => {
|
||||
await bypass(document.id).unwrap();
|
||||
onChanged();
|
||||
}}
|
||||
>
|
||||
Complete test payment
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{(document.status === "PAID" || document.status === "PAYMENT_CONFIRMED") && (
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />} mt="md">
|
||||
Payment received. The Authority will schedule a date for you to collect your {title}.
|
||||
</Alert>
|
||||
)}
|
||||
{document.status === "SCHEDULED" && document.scheduledIssuanceDate && (
|
||||
<Alert variant="light" color="grape" icon={<IconPrinter size={17} />} mt="md">
|
||||
Your {title} is ready for collection on{" "}
|
||||
<strong>{formatDate(document.scheduledIssuanceDate)}</strong>. Please visit the EMA
|
||||
office on that date, bringing your National ID.
|
||||
</Alert>
|
||||
)}
|
||||
{document.status === "ISSUED" && (
|
||||
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<span>
|
||||
Your {title} <strong>{document.documentNumber}</strong> was issued
|
||||
{document.issueDate && ` on ${formatDate(document.issueDate)}`}
|
||||
{document.expiryDate && `, valid until ${formatDate(document.expiryDate)}`}.
|
||||
</span>
|
||||
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
{document.status === "REJECTED" && (
|
||||
<Alert variant="light" color="red" icon={<IconInfoCircle size={17} />} mt="md">
|
||||
{document.rejectionReason ?? "This request was rejected."}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeamanBookPage({
|
||||
service = "COMBINED",
|
||||
}: {
|
||||
service?: "COMBINED" | "SEAMAN_BOOK" | "BTC";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
/**
|
||||
* The Seaman Book and Basic Training Certificate — requested automatically
|
||||
* with the seafarer registration, tracked here through payment, collection
|
||||
* and issue.
|
||||
*/
|
||||
export function SeamanBookPage({ service = "COMBINED" }: { service?: "COMBINED" | "SEAMAN_BOOK" | "BTC" }) {
|
||||
// Polled: payment confirmation, scheduling and issue happen in other sessions.
|
||||
const { data, isLoading, refetch } = useGetMySeafarerDocumentsQuery(undefined, {
|
||||
pollingInterval: 15_000,
|
||||
});
|
||||
const isBtc = service === "BTC";
|
||||
const isCombined = service === "COMBINED";
|
||||
|
||||
// Polled, not fetch-once: the officer who claims/reviews/approves this
|
||||
// application (and the auto-promotion when the parent seafarer
|
||||
// registration is approved) all happen in a different session, so nothing
|
||||
// in this tab would otherwise tell RTK Query the status changed underneath
|
||||
// it — the applicant would see a stale "Draft"/"Payment Pending" until they
|
||||
// manually reloaded. `useApiQuery` is a generic untagged passthrough (many
|
||||
// unrelated callers share it), so polling this one call is the fix that
|
||||
// doesn't risk over-invalidating everyone else's cache.
|
||||
const { data, isLoading, refetch } = useApiQuery<SeamanBookOverview>(
|
||||
{
|
||||
url: "/seaman-book/my",
|
||||
method: "GET",
|
||||
},
|
||||
{ pollingInterval: 15_000 },
|
||||
);
|
||||
const { data: paymentCapabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const [bypassPayment, { isLoading: bypassingPayment }] =
|
||||
useBypassPaymentMutation();
|
||||
|
||||
const completeTestPayment = async (applicationId: string) => {
|
||||
await bypassPayment(applicationId).unwrap();
|
||||
refetch();
|
||||
};
|
||||
|
||||
const application = data?.application ?? null;
|
||||
const btcApplication = data?.btcApplication ?? null;
|
||||
const eligibility = data?.eligibility;
|
||||
const bstItems = eligibility?.bstModules ?? [];
|
||||
const bstDone = bstItems.filter((b) => b.done).length;
|
||||
|
||||
// The server decides: the same checklist gates the submission, so a screen
|
||||
// that judged eligibility for itself could offer a button the API refuses.
|
||||
const isEligible = data?.eligible ?? false;
|
||||
// Either service already being in flight means there is nothing to apply for
|
||||
// here — an approved registration opens both, so offering "Apply" alongside
|
||||
// them would invite a duplicate the server refuses anyway.
|
||||
const submitted = Boolean(
|
||||
isCombined
|
||||
? application || btcApplication
|
||||
: isBtc
|
||||
? btcApplication
|
||||
: application,
|
||||
);
|
||||
const shown = [
|
||||
...(isCombined || !isBtc ? [data?.seamanBook] : []),
|
||||
...(isCombined || isBtc ? [data?.btc] : []),
|
||||
].filter((d): d is SeafarerDocument => Boolean(d));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>
|
||||
My Application —{" "}
|
||||
{isCombined
|
||||
? "Seaman Book & Basic Training Certificate"
|
||||
: isBtc
|
||||
@@ -293,348 +208,42 @@ export function SeamanBookPage({
|
||||
: "Seaman Book"}
|
||||
</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{isCombined
|
||||
? "Track both applications together and pay each service separately."
|
||||
: isBtc
|
||||
? "Track and manage your Basic Training Certificate application."
|
||||
: "A Seaman Book is your official maritime identity document. It records your sea service and must be held before joining any vessel."}
|
||||
Both are requested for you when you register as a seafarer and released to payment once
|
||||
the registration is approved. Each is paid for separately.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Active application status — one card per service in flight. */}
|
||||
{(isCombined || !isBtc) && application && (
|
||||
<ApplicationCard title="Seaman Book" application={application}>
|
||||
{application.status === "PAYMENT_PENDING" && (
|
||||
<Group mt="md">
|
||||
<Button
|
||||
loading={isPaying}
|
||||
onClick={() => pay(application.applicationId)}
|
||||
>
|
||||
Pay now
|
||||
</Button>
|
||||
{paymentCapabilities?.bypassEnabled && (
|
||||
<Button
|
||||
variant="default"
|
||||
loading={bypassingPayment}
|
||||
onClick={() => completeTestPayment(application.applicationId)}
|
||||
>
|
||||
Complete test payment
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{data?.book ? (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="teal"
|
||||
icon={<IconPrinter size={17} />}
|
||||
mt="md"
|
||||
>
|
||||
Your Seaman Book <strong>{data.book.id}</strong> has been issued.
|
||||
Please visit the EMA office to collect it, bringing your National
|
||||
ID.
|
||||
</Alert>
|
||||
) : (
|
||||
application.status === "SCHEDULED" &&
|
||||
application.scheduledIssuanceDate && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="grape"
|
||||
icon={<IconPrinter size={17} />}
|
||||
mt="md"
|
||||
>
|
||||
Your Seaman Book is ready for collection on{" "}
|
||||
<strong>{formatDate(application.scheduledIssuanceDate)}</strong>
|
||||
. Please visit the EMA office on that date, bringing your
|
||||
National ID.
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
</ApplicationCard>
|
||||
)}
|
||||
{(isCombined || isBtc) && btcApplication && (
|
||||
<ApplicationCard
|
||||
title="Basic Training Certificate"
|
||||
application={btcApplication}
|
||||
>
|
||||
{btcApplication.status === "PAYMENT_PENDING" && (
|
||||
<Group mt="md">
|
||||
<Button
|
||||
loading={isPaying}
|
||||
onClick={() => pay(btcApplication.applicationId)}
|
||||
>
|
||||
Pay now
|
||||
</Button>
|
||||
{paymentCapabilities?.bypassEnabled && (
|
||||
<Button
|
||||
variant="default"
|
||||
loading={bypassingPayment}
|
||||
onClick={() =>
|
||||
completeTestPayment(btcApplication.applicationId)
|
||||
}
|
||||
>
|
||||
Complete test payment
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{btcApplication.status === "SCHEDULED" &&
|
||||
btcApplication.scheduledIssuanceDate && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="grape"
|
||||
icon={<IconPrinter size={17} />}
|
||||
mt="md"
|
||||
>
|
||||
Your Basic Training Certificate is ready for collection on{" "}
|
||||
<strong>
|
||||
{formatDate(btcApplication.scheduledIssuanceDate)}
|
||||
</strong>
|
||||
. Please visit the EMA office on that date, bringing your
|
||||
National ID.
|
||||
</Alert>
|
||||
)}
|
||||
</ApplicationCard>
|
||||
{isLoading ? (
|
||||
<Center h={160}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : shown.length === 0 ? (
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
Nothing requested yet. Complete and submit your seafarer registration — a Seaman Book and a
|
||||
Basic Training Certificate are applied for with it.
|
||||
</Alert>
|
||||
) : (
|
||||
shown.map((document) => <DocumentCard key={document.id} document={document} onChanged={refetch} />)
|
||||
)}
|
||||
|
||||
{/* No active application — eligibility + apply */}
|
||||
{!submitted && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{/* Eligibility checklist */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={isEligible ? "teal" : "orange"}
|
||||
size={36}
|
||||
radius="md"
|
||||
>
|
||||
<IconShield size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Eligibility Requirements</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<EligibilityItem
|
||||
label="Profile completed (name, DOB, nationality)"
|
||||
ok={Boolean(eligibility?.hasProfile)}
|
||||
/>
|
||||
<EligibilityItem
|
||||
label="Registered seafarer number issued"
|
||||
ok={Boolean(eligibility?.hasSeafarerNumber)}
|
||||
/>
|
||||
<EligibilityItem
|
||||
label={
|
||||
eligibility?.medicalExpiry
|
||||
? `Valid medical certificate (expires ${formatDate(eligibility.medicalExpiry)})`
|
||||
: "Valid medical certificate uploaded"
|
||||
}
|
||||
ok={Boolean(eligibility?.hasMedical)}
|
||||
/>
|
||||
|
||||
<Divider
|
||||
label={`Basic Safety Training (all ${bstItems.length || 5} required)`}
|
||||
labelPosition="left"
|
||||
my={4}
|
||||
/>
|
||||
{bstItems.map((item) => (
|
||||
<EligibilityItem
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
ok={item.done}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isLoading && !isEligible && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={<IconAlertCircle size={15} />}
|
||||
mt="xs"
|
||||
p="sm"
|
||||
>
|
||||
<Text fz="xs">
|
||||
Complete all requirements above before applying.
|
||||
{bstItems.length > bstDone
|
||||
? ` Missing BST: ${bstItems.length - bstDone} certificate(s).`
|
||||
: ""}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isEligible && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="teal"
|
||||
icon={<IconCircleCheck size={15} />}
|
||||
mt="xs"
|
||||
p="sm"
|
||||
>
|
||||
<Text fz="xs">
|
||||
You meet all requirements. You may proceed with your
|
||||
application.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Application form */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>New Application</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c="dimmed" lh={1.6}>
|
||||
Upon submitting your application, EMA Registration Officers will
|
||||
verify your profile, documents, medical certificate, and Basic
|
||||
Safety Training certificates. You will be notified at each stage
|
||||
by email and SMS.
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text fw={600} fz="sm">
|
||||
What will be verified:
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
"Full seafarer profile",
|
||||
"National ID / Fayda authenticity",
|
||||
"Medical certificate validity",
|
||||
"All 5 Basic Safety Training certificates",
|
||||
"Passport size photo",
|
||||
].map((item) => (
|
||||
<Group key={item} gap="xs">
|
||||
<IconCircleCheck
|
||||
size={15}
|
||||
color="var(--mantine-color-teal-6)"
|
||||
/>
|
||||
<Text fz="sm">{item}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Card withBorder radius="sm" p="sm">
|
||||
<Group gap="xs">
|
||||
<IconClock size={15} color="var(--mantine-color-blue-6)" />
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Processing time
|
||||
</Text>
|
||||
<Text fz="sm" fw={600}>
|
||||
5–7 working days
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder radius="sm" p="sm">
|
||||
<Group gap="xs">
|
||||
<IconHeart size={15} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Medical validity
|
||||
</Text>
|
||||
<Text fz="sm" fw={600}>
|
||||
2 years (STCW)
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={15} />}
|
||||
p="xs"
|
||||
>
|
||||
<Text fz="xs">
|
||||
Application fee will be communicated during the review
|
||||
process. Payment can be made online or at the EMA office.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
leftSection={<IconBook2 size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
isBtc
|
||||
? "/licensing/BTC_BASIC_TRAINING/apply"
|
||||
: "/seaman-book/apply",
|
||||
)
|
||||
}
|
||||
disabled={!isEligible}
|
||||
size="md"
|
||||
>
|
||||
Start Application
|
||||
</Button>
|
||||
|
||||
{!isEligible && (
|
||||
<Text fz="xs" c="dimmed" ta="center">
|
||||
Complete all eligibility requirements to enable this button.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Info box */}
|
||||
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
|
||||
<Text fw={700} fz="sm">
|
||||
About the{" "}
|
||||
{isCombined
|
||||
? "Seaman Book & Basic Training Certificate"
|
||||
: isBtc
|
||||
? "Basic Training Certificate"
|
||||
: "Seaman Book"}
|
||||
About the {isCombined ? "Seaman Book & Basic Training Certificate" : isBtc ? "Basic Training Certificate" : "Seaman Book"}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{(isBtc
|
||||
? [
|
||||
{
|
||||
icon: IconShield,
|
||||
title: "STCW Training",
|
||||
desc: "Confirms completion of the required basic maritime safety training.",
|
||||
},
|
||||
{
|
||||
icon: IconFileDescription,
|
||||
title: "Certificate Record",
|
||||
desc: "Keeps your approved basic training evidence available in one place.",
|
||||
},
|
||||
{
|
||||
icon: IconCircleCheck,
|
||||
title: "Verified",
|
||||
desc: "Issued after EMA verifies the applicable training requirements.",
|
||||
},
|
||||
{ icon: IconShield, title: "STCW Training", desc: "Confirms completion of the required basic maritime safety training." },
|
||||
{ icon: IconFileDescription, title: "Certificate Record", desc: "Keeps your approved basic training evidence available in one place." },
|
||||
{ icon: IconCircleCheck, title: "Verified", desc: "Issued after EMA verifies the applicable training requirements." },
|
||||
]
|
||||
: [
|
||||
{
|
||||
icon: IconBook2,
|
||||
title: "Official Identity",
|
||||
desc: "Internationally recognized maritime identity document required before joining any vessel.",
|
||||
},
|
||||
{
|
||||
icon: IconFileDescription,
|
||||
title: "Service Record",
|
||||
desc: "Records all your sea service, vessel assignments, and employment history.",
|
||||
},
|
||||
{
|
||||
icon: IconShield,
|
||||
title: "STCW Compliance",
|
||||
desc: "Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.",
|
||||
},
|
||||
{ icon: IconBook2, title: "Official Identity", desc: "Internationally recognized maritime identity document required before joining any vessel." },
|
||||
{ icon: IconFileDescription, title: "Service Record", desc: "Records all your sea service, vessel assignments, and employment history." },
|
||||
{ icon: IconShield, title: "STCW Compliance", desc: "Required under STCW for all seafarers. Must be renewed and kept valid throughout your career." },
|
||||
]
|
||||
).map(({ icon: Icon, title, desc }) => (
|
||||
<Box key={title}>
|
||||
|
||||
@@ -33,7 +33,6 @@ import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
// Phase 1 pages
|
||||
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||
import { SeamanBookPage } from "./features/seaman-book/pages/SeamanBookPage";
|
||||
import { SeamanBookApplicationPage } from "./features/seaman-book/pages/SeamanBookApplicationPage";
|
||||
import { MedicalCertificatePage } from "./features/medical/pages/MedicalCertificatePage";
|
||||
import { BasicSafetyTrainingPage } from "./features/basic-safety-training/pages/BasicSafetyTrainingPage";
|
||||
import { NotificationsPage } from "./features/notifications/pages/NotificationsPage";
|
||||
@@ -238,16 +237,8 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/seaman-book/apply",
|
||||
element: (
|
||||
<RequirePermission
|
||||
anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}
|
||||
>
|
||||
<SeamanBookApplicationPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
// Requested automatically with the seafarer registration — nothing to file.
|
||||
{ path: "/seaman-book/apply", element: <Navigate to="/seaman-book" replace /> },
|
||||
{
|
||||
path: "/medical",
|
||||
element: (
|
||||
|
||||
Reference in New Issue
Block a user