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:
Nati
2026-08-20 08:39:17 +00:00
parent 5a802b5dfc
commit 8eb4c38216
27 changed files with 1081 additions and 1325 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
}

View File

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

View File

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