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

@@ -0,0 +1,161 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
useListSeafarerDocumentsQuery,
type SeafarerDocumentKind,
type SeafarerDocumentRow,
type SeafarerDocumentStatus,
} from '@ema-platform/api';
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const PAGE_SIZE = 10;
/** Statuses an officer filters by — held and withdrawn requests are not work. */
const STATUS_FILTERS = (
['PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'SCHEDULED', 'ISSUED', 'REJECTED'] as SeafarerDocumentStatus[]
).map((value) => ({ value, label: SEAFARER_DOCUMENT_STATUS_LABELS[value] }));
/**
* The Seaman Book queue and the BTC queue — one page, keyed by kind. Requests
* appear here once the seafarer registration that opened them is approved.
*/
export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind }) {
const navigate = useNavigate();
const showDate = useDateDisplayer();
const [status, setStatus] = useState<SeafarerDocumentStatus | null>(null);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const { data, isLoading, isFetching, refetch } = useListSeafarerDocumentsQuery({
kind,
status: status ?? undefined,
search: debouncedSearch || undefined,
take: pageSize,
skip: page * pageSize,
});
const columns: AdvancedColumn<SeafarerDocumentRow>[] = useMemo(
() => [
{
header: 'Request №',
accessorKey: 'requestNumber',
cell: ({ row }) => (
<div>
<Text size="sm" ff="monospace">
{row.original.requestNumber}
</Text>
{row.original.documentNumber && (
<Text size="xs" c="teal" ff="monospace">
{row.original.documentNumber}
</Text>
)}
</div>
),
},
{
header: 'Seafarer',
accessorKey: 'applicant.name',
cell: ({ row }) => (
<div>
<Text size="sm" fw={500}>
{row.original.applicant?.name ?? '—'}
</Text>
<Text size="xs" c="dimmed" ff="monospace">
{row.original.applicant?.seafarerNumber ?? '—'}
</Text>
</div>
),
},
{
header: 'Fee',
accessorKey: 'feeAmount',
cell: ({ row }) => (
<Text size="sm">
{row.original.feeAmount !== null ? `${row.original.feeAmount} ${row.original.feeCurrency}` : '—'}
</Text>
),
},
{
header: 'Released',
accessorKey: 'submittedAt',
cell: ({ row }) => (
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
),
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => (
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[row.original.status]}>
{SEAFARER_DOCUMENT_STATUS_LABELS[row.original.status]}
</Badge>
),
},
],
[showDate],
);
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
{SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue
</Title>
<Text size="sm" c="dimmed" mb="md">
Requests released by an approved seafarer registration: confirm payment, schedule the
collection date, then issue.
</Text>
<Group mb="md" gap="sm">
<TextInput
placeholder="Search number, name or seafarer №…"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={280}
/>
<Select
placeholder="All statuses"
data={STATUS_FILTERS}
value={status}
onChange={(v) => {
setStatus(v as SeafarerDocumentStatus | null);
setPage(0);
}}
clearable
w={220}
/>
</Group>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
itemCount={data?.total ?? 0}
pageIndex={page}
onPageChange={setPage}
pageSize={pageSize}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(0);
}}
refresh={refetch}
isLoading={isLoading || isFetching}
emptyText="No requests match."
onRowClick={(row) => navigate(`/seafarer-documents/${row.id}`)}
/>
</Container>
);
}
export const SeamanBookQueuePage = () => <SeafarerDocumentQueuePage kind="SEAMAN_BOOK" />;
export const BtcQueuePage = () => <SeafarerDocumentQueuePage kind="BTC_BASIC_TRAINING" />;

View File

@@ -0,0 +1,276 @@
import { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Center,
Container,
Group,
Loader,
Modal,
Paper,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage,
useConfirmSeafarerDocumentPaymentMutation,
useGetSeafarerDocumentReviewQuery,
useIssueSeafarerDocumentMutation,
useLazyGetSeafarerDocumentReviewDownloadQuery,
useRejectSeafarerDocumentMutation,
useScheduleSeafarerDocumentMutation,
} from '@ema-platform/api';
import { AmharicDatePicker, notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const;
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Table.Tr>
<Table.Td w="40%">
<Text size="xs" c="dimmed">
{label}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" component="div">
{value ?? '—'}
</Text>
</Table.Td>
</Table.Tr>
);
}
/** One Seaman Book / BTC request: payment → collection date → issue, or reject. */
export function SeafarerDocumentReviewPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const showDate = useDateDisplayer();
const { data, isLoading, error } = useGetSeafarerDocumentReviewQuery(id, { skip: !id });
const [confirmPayment, { isLoading: confirming }] = useConfirmSeafarerDocumentPaymentMutation();
const [schedule, { isLoading: scheduling }] = useScheduleSeafarerDocumentMutation();
const [issue, { isLoading: issuing }] = useIssueSeafarerDocumentMutation();
const [reject, { isLoading: rejecting }] = useRejectSeafarerDocumentMutation();
const [getDownload, { isFetching: downloading }] = useLazyGetSeafarerDocumentReviewDownloadQuery();
const [scheduleOpen, setScheduleOpen] = useState(false);
const [pickupDate, setPickupDate] = useState('');
const [rejectOpen, setRejectOpen] = useState(false);
const [reason, setReason] = useState('');
if (isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
if (error || !data) {
return (
<Container size="md" py="xl">
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{extractErrorMessage(error, 'Could not load this request.')}
</Alert>
</Container>
);
}
const { document, applicant, payment } = data;
const kindLabel = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
const terminal = ['ISSUED', 'REJECTED', 'CANCELLED'].includes(document.status);
async function run(action: () => Promise<unknown>, done: string) {
try {
await action();
notify.success(done);
setScheduleOpen(false);
setRejectOpen(false);
setReason('');
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not record the action'));
}
}
async function download() {
try {
const { url } = await getDownload(id).unwrap();
window.open(url, '_blank', 'noopener');
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not fetch the PDF'));
}
}
return (
<Container size="lg" py="md">
<Button
variant="subtle"
size="xs"
leftSection={<IconArrowLeft size={14} />}
onClick={() => navigate(QUEUE_PATH[document.kind])}
mb="xs"
>
Back to queue
</Button>
<Group justify="space-between" align="flex-start" mb="md">
<div>
<Title order={3}>
{kindLabel} {applicant?.name ?? '—'}
</Title>
<Group gap="xs" mt={4}>
<Text size="sm" c="dimmed" ff="monospace">
{document.requestNumber}
</Text>
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]}>
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
{document.documentNumber && (
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
{document.documentNumber}
</Badge>
)}
</Group>
</div>
<Group gap="xs">
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CONFIRM_PAYMENT]} hideOnly>
<Button loading={confirming} onClick={() => run(() => confirmPayment(id).unwrap(), 'Payment confirmed')}>
Confirm payment
</Button>
</RequirePermission>
)}
{document.status === 'PAYMENT_CONFIRMED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.SCHEDULE_ISSUANCE]} hideOnly>
<Button onClick={() => setScheduleOpen(true)}>Schedule pickup</Button>
</RequirePermission>
)}
{(document.status === 'SCHEDULED' || document.status === 'PAYMENT_CONFIRMED') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.ISSUE_CERTIFICATE]} hideOnly>
<Button color="teal" loading={issuing} onClick={() => run(() => issue(id).unwrap(), `${kindLabel} issued`)}>
Issue
</Button>
</RequirePermission>
)}
{document.status === 'ISSUED' && (
<Button variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF
</Button>
)}
{!terminal && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REJECT_APPLICATION]} hideOnly>
<Button color="red" variant="light" onClick={() => setRejectOpen(true)}>
Reject
</Button>
</RequirePermission>
)}
</Group>
</Group>
{document.status === 'REJECTED' && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
{document.rejectionReason}
</Alert>
)}
<Paper withBorder p="lg" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb={4}>
Seafarer
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
<Row label="Name" value={applicant?.name} />
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
<Row
label="Registration"
value={
applicant?.registrationId ? (
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
{applicant.registrationNumber}
</Link>
) : (
applicant?.registrationNumber
)
}
/>
</Table.Tbody>
</Table>
</div>
<div>
<Text fw={600} size="sm" mb={4}>
Payment
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
<Row label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
<Row label="Provider" value={payment?.provider} />
<Row label="Reference" value={document.paymentReference ?? payment?.providerRef} />
</Table.Tbody>
</Table>
</div>
<div>
<Text fw={600} size="sm" mb={4}>
Issuance
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
<Row label="Document №" value={document.documentNumber} />
<Row label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
<Row label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
</Table.Tbody>
</Table>
</div>
</Stack>
</Paper>
<Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
<Stack>
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />
<Group justify="flex-end">
<Button variant="default" onClick={() => setScheduleOpen(false)}>
Cancel
</Button>
<Button
disabled={!pickupDate}
loading={scheduling}
onClick={() => run(() => schedule({ id, scheduledDate: pickupDate }).unwrap(), 'Pickup scheduled')}
>
Schedule
</Button>
</Group>
</Stack>
</Modal>
<Modal opened={rejectOpen} onClose={() => setRejectOpen(false)} title={`Reject ${kindLabel}`} centered>
<Stack>
<Textarea label="Reason (shown to the seafarer)" required minRows={3} value={reason} onChange={(e) => setReason(e.currentTarget.value)} data-autofocus />
<Group justify="flex-end">
<Button variant="default" onClick={() => setRejectOpen(false)}>
Cancel
</Button>
<Button color="red" disabled={reason.trim().length < 3} loading={rejecting} onClick={() => run(() => reject({ id, reason: reason.trim() }).unwrap(), 'Request rejected')}>
Confirm
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
export default SeafarerDocumentReviewPage;

View File

@@ -17,7 +17,7 @@ import {
Textarea,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconInfoCircle } from '@tabler/icons-react';
import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react';
import {
SEAFARER_REGISTRATION_DOCUMENTS,
SEAFARER_REGISTRATION_FIELD_LABELS,
@@ -27,14 +27,12 @@ import {
displaySeafarerAnswer,
extractErrorMessage,
useApproveSeafarerRegistrationMutation,
useClaimSeafarerRegistrationMutation,
useGetSeafarerRegistrationReviewQuery,
useRejectSeafarerRegistrationMutation,
useRequestSeafarerRegistrationChangesMutation,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
import { applicantName } from './SeafarerRegistrationQueuePage';
type Decision = 'approve' | 'reject' | 'changes';
@@ -49,10 +47,8 @@ const DECISION_COPY: Record<Decision, { title: string; label: string; color: str
export function SeafarerRegistrationReviewPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const me = useAppSelector((state) => state.auth.user);
const { data, isLoading, error } = useGetSeafarerRegistrationReviewQuery(id, { skip: !id });
const [claim, { isLoading: claiming }] = useClaimSeafarerRegistrationMutation();
const [approve, { isLoading: approving }] = useApproveSeafarerRegistrationMutation();
const [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation();
const [requestChanges, { isLoading: requesting }] = useRequestSeafarerRegistrationChangesMutation();
@@ -78,8 +74,8 @@ export function SeafarerRegistrationReviewPage() {
}
const { registration, attachments } = data;
const mine = !registration.assignedOfficerId || registration.assignedOfficerId === me?.id;
const canDecide = registration.status === 'UNDER_REVIEW' && mine;
// Decided straight off the queue — no claim step.
const canDecide = registration.status === 'SUBMITTED';
const busy = approving || rejecting || requesting;
async function run(action: () => Promise<unknown>, done: string) {
@@ -131,13 +127,6 @@ export function SeafarerRegistrationReviewPage() {
</Group>
</div>
<Group gap="xs">
{registration.status === 'SUBMITTED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]} hideOnly>
<Button loading={claiming} onClick={() => run(() => claim(id).unwrap(), 'Claimed — it is yours to review.')}>
Claim
</Button>
</RequirePermission>
)}
{canDecide && (
<>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REQUEST_ADJUSTMENT]} hideOnly>
@@ -160,11 +149,6 @@ export function SeafarerRegistrationReviewPage() {
</Group>
</Group>
{registration.status === 'UNDER_REVIEW' && !mine && (
<Alert color="gray" icon={<IconInfoCircle size={16} />} mb="md">
Assigned to another officer only they can decide it.
</Alert>
)}
{registration.status === 'RESUBMIT_REQUIRED' && (
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
{registration.reviewRemark}

View File

@@ -1,21 +0,0 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function SeamanBookQueuePage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Seaman Book queue"
description="Seaman Book applications are not connected to the backend yet."
/>
</Container>
);
}
export default SeamanBookQueuePage;

View File

@@ -98,8 +98,8 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/SEAMAN_BOOK', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/BTC_BASIC_TRAINING', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },

View File

@@ -27,7 +27,8 @@ import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfi
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage';
import { SeamanBookQueuePage } from '../features/seaman-book-queue/pages/SeamanBookQueuePage';
import { BtcQueuePage, SeamanBookQueuePage } from '../features/seafarer-document-review/pages/SeafarerDocumentQueuePage';
import { SeafarerDocumentReviewPage } from '../features/seafarer-document-review/pages/SeafarerDocumentReviewPage';
import { QuestionPage } from '../features/question/pages/QuestionPage';
import { ExamPage } from '../features/exam/pages/ExamPage';
import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
@@ -90,7 +91,12 @@ const router = createBrowserRouter([
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
{ path: 'licence-review/type/SEAFARER_REGISTRATION', element: <Navigate to="/seafarer-registrations" replace /> },
// Seaman Book and BTC are not licences: own queues, own review.
{ path: 'seaman-book-queue', element: guard(APPLICATION_QUEUE, <SeamanBookQueuePage />) },
{ path: 'btc-queue', element: guard(APPLICATION_QUEUE, <BtcQueuePage />) },
{ path: 'seafarer-documents/:id', element: guard(APPLICATION_QUEUE, <SeafarerDocumentReviewPage />) },
{ path: 'licence-review/type/SEAMAN_BOOK', element: <Navigate to="/seaman-book-queue" replace /> },
{ path: 'licence-review/type/BTC_BASIC_TRAINING', element: <Navigate to="/btc-queue" replace /> },
{ path: 'questions', element: guard([P.APPROVE_QUESTION, P.AUTHOR_QUESTION], <QuestionPage />) },
{ path: 'exams', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamPage />) },
{ path: 'exams/:id', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamDetailPage />) },

View File

@@ -6,55 +6,66 @@ import {
verifyOtpIfPrompted,
} from './support/applicant';
import { deleteApplicant, sql, sqlValue } from './support/db';
import { approveRegistration, runRegistrationWorkflow } from './support/workflow';
import {
approveRegistration,
runDocumentWorkflow,
runRegistrationWorkflow,
} from './support/workflow';
import { act, logInAsOfficer, openInQueue } from './support/officer';
/**
* Seafarer registration, applicant through to approval.
*
* Registration is its own table and its own endpoints — not a licence
* application. Approval is the one step with consequences beyond its row: it
* stamps a permanent number on the profile, activates the seafarer record,
* records the medical certificate, and opens the Seaman Book and BTC
* applications on the applicant's behalf. Those effects only fire at approval,
* so nothing short of driving a registration into an officer's hands
* exercises them.
* application. Submitting one requests a Seaman Book and a BTC by default
* (`seafarer_documents`); approval stamps a permanent number on the profile,
* activates the seafarer record, records the medical certificate, and releases
* those two requests to payment. Those effects only fire at approval, so
* nothing short of driving a registration into an officer's hands exercises
* them.
*/
/**
* Fills the profile the registration form prefills its Identity Details step
* from. Not a precondition — the step collects these itself — but a populated
* profile is the returning applicant's case, and it is the prefill that keeps
* them from retyping.
* Gives the applicant the profile the registration form prefills from.
*
* Written directly rather than through `/profile`: these tests are about the
* registration, and the profile form is a separate surface with its own
* tests — driving its tabs here made every registration test fail whenever
* that form changed. The rows are what the Address and Maritime tabs save.
*/
async function completeProfile(page: Page, applicant: Applicant): Promise<void> {
await page.goto('/profile');
await openTab(page, 'Profile');
await page.getByLabel('First Name').fill(applicant.firstName);
await page.getByLabel('Middle Name').fill(applicant.middleName);
await page.getByLabel('Last Name').fill(applicant.lastName);
await pick(page, 'Gender', /male/i);
await pickDate(page, 'Date of Birth', '1995-04-12');
await pick(page, 'Marital Status', /single/i);
await pick(page, 'Profession', /./);
await save(page);
await openTab(page, 'Address');
await pick(page, 'ID Type', /^national id$/i);
await page.getByLabel('ID Number').fill('FYD1234567890');
await pick(page, 'Nationality', /ethiopia/i);
await save(page);
function completeProfile(applicant: Applicant): void {
const email = applicant.email.replace(/'/g, "''");
sql(`
WITH addr AS (
INSERT INTO addresses (id_type, id_number, nationality, primary_phone_number, email)
VALUES ('NID', 'FYD1234567890', 'Ethiopian', '${applicant.phoneNumber}', '${email}')
RETURNING id
)
UPDATE profiles p
SET first_name = '${applicant.firstName}',
middle_name = '${applicant.middleName}',
last_name = '${applicant.lastName}',
gender = 'MALE',
dob = '1995-04-12',
marital_status = 'SINGLE',
address_id = (SELECT id FROM addr)
WHERE p.user_id = (SELECT id FROM iam.users WHERE email = '${email}')
`);
}
async function openTab(page: Page, name: string): Promise<void> {
await page.getByRole('tab', { name, exact: true }).click();
await expect(page.getByRole('tabpanel', { name })).toBeVisible({ timeout: 15_000 });
function profileIdOf(email: string): string | null {
return sqlValue(`
SELECT p.id FROM profiles p JOIN iam.users u ON u.id = p.user_id
WHERE u.email = '${email}'
`);
}
async function pick(page: Page, label: string, option: RegExp): Promise<void> {
await page.getByRole('textbox', { name: label }).click();
await page.getByRole('option', { name: option }).first().click();
// Matched on text, not accessible name: CountrySelect renders each option's
// label inside a nested element, which leaves the option itself unnamed.
await page.getByRole('option').filter({ hasText: option }).first().click();
}
/** Drives the AmharicDatePicker's own UI — a native `value` write bypasses `onChange`. */
@@ -76,28 +87,6 @@ async function pickDate(page: Page, label: string, iso: string): Promise<void> {
});
}
async function save(page: Page): Promise<void> {
const saved = page.waitForResponse(
(r) =>
r.request().method() !== 'GET' &&
r.status() < 400 &&
/(profile|address|user)/i.test(r.url()),
{ timeout: 20_000 },
);
await page.getByRole('button', { name: /save/i }).first().click();
try {
await saved;
} catch (cause) {
const messages = await page.locator('.mantine-InputWrapper-error').allTextContents();
throw new Error(
messages.length
? `Save did not submit — validation errors: ${messages.join('; ')}`
: 'Save produced no request and reported no field error.',
{ cause },
);
}
}
/** Signs up, declares seafarer operations, and fills the profile. */
async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
const offset = await signUp(page, applicant);
@@ -109,8 +98,9 @@ async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
.check();
await page.getByRole('button', { name: /save operations/i }).click();
await expect(page).toHaveURL(/\/seafarer-registration/, { timeout: 30_000 });
await page.goto('/profile');
await completeProfile(page, applicant);
// The portal has provisioned the profile by now (the page read it); fill it.
await expect.poll(() => profileIdOf(applicant.email), { timeout: 30_000 }).toBeTruthy();
completeProfile(applicant);
}
test.describe('seafarer registration', () => {
@@ -182,7 +172,6 @@ test.describe('seafarer registration', () => {
expect(statusOf(number)).toBe('SUBMITTED');
await runRegistrationWorkflow(id, [
{ path: 'claim' },
{ path: 'request-changes', data: { remark: 'Medical certificate is illegible.' } },
]);
expect(statusOf(number)).toBe('RESUBMIT_REQUIRED');
@@ -206,14 +195,14 @@ test.describe('seafarer registration', () => {
await submit(id, applicant);
await runRegistrationWorkflow(id, [
{ path: 'claim' },
{ path: 'reject', data: { reason: 'Basic training evidence incomplete.' } },
]);
expect(statusOf(number)).toBe('REJECTED');
// A rejection is terminal: nothing is numbered, nothing is opened.
// A rejection is terminal: nothing is numbered, and the documents requested
// with the registration are withdrawn rather than left waiting.
expect(seafarerNumberOf(applicant.email)).toBeNull();
expect(childrenOf(applicant.email)).toHaveLength(0);
expect(documentsOf(applicant.email).map((r) => r[1])).toEqual(['CANCELLED', 'CANCELLED']);
});
test('approval numbers the profile and opens both child applications', async ({
@@ -250,13 +239,13 @@ test.describe('seafarer registration', () => {
).toBe('VERIFIED');
// The applicant is not made to apply twice more for the documents that
// prove what they have just been told: both are opened, straight to payment.
const children = childrenOf(applicant.email);
expect(children.map((r) => [r[0], r[1]])).toEqual([
['BTC_BASIC_TRAINING', 'PAYMENT_PENDING'],
['SEAMAN_BOOK', 'PAYMENT_PENDING'],
// prove what they have just been told: both were requested at submission
// and are now released to payment, each with its own fee.
const documents = documentsOf(applicant.email);
expect(documents.map((r) => [r[0], r[1], r[2]])).toEqual([
['BTC_BASIC_TRAINING', 'PAYMENT_PENDING', '250.00'],
['SEAMAN_BOOK', 'PAYMENT_PENDING', '400.00'],
]);
expect(children.every((r) => r[2] === 'AUTO_SEAFARER_APPROVAL')).toBe(true);
// The portal now shows the outcome rather than a form.
await page.goto('/seafarer-registration');
@@ -282,7 +271,62 @@ test.describe('seafarer registration', () => {
]);
expect(code).toBeGreaterThanOrEqual(400);
expect(seafarerNumberOf(applicant.email)).toBe(first);
expect(childrenOf(applicant.email)).toHaveLength(2);
expect(documentsOf(applicant.email)).toHaveLength(2);
});
test('a released document is paid, scheduled and issued from its own queue', async ({
page,
}) => {
await readyApplicant(page, applicant);
await page.goto('/seafarer-registration');
const number = await waitForRegistration(applicant.email);
const id = idOf(number);
await submit(id, applicant);
// Requested with the submission, held until approval.
expect(documentsOf(applicant.email).map((r) => r[1])).toEqual([
'AWAITING_REGISTRATION',
'AWAITING_REGISTRATION',
]);
await approveRegistration(id);
const btc = documentsOf(applicant.email).find((r) => r[0] === 'BTC_BASIC_TRAINING');
if (!btc) throw new Error('No BTC request opened');
const btcId = btc[3];
// Nothing can be issued before the fee is settled.
const [refused] = await runDocumentWorkflow(btcId, [{ path: 'issue', expectFailure: true }]);
expect(refused).toBeGreaterThanOrEqual(400);
// The test bypass settles the fee as the applicant; PAYMENT_AUTO_CONFIRM in
// the suite's environment confirms it without a finance officer.
await runDocumentWorkflow(btcId, [{ path: 'payments/bypass' }], applicant);
expect(documentStatus(btcId)).toBe('PAYMENT_CONFIRMED');
await runDocumentWorkflow(btcId, [
{ path: 'schedule-issuance', data: { scheduledDate: '2026-09-01' } },
]);
expect(documentStatus(btcId)).toBe('SCHEDULED');
await runDocumentWorkflow(btcId, [{ path: 'issue' }]);
const issued = sql(`
SELECT status, document_number, expiry_date, verification_code
FROM seafarer_documents WHERE id = '${btcId}'
`)[0];
expect(issued[0]).toBe('ISSUED');
expect(issued[1]).toMatch(/^BTC/);
expect(issued[2]).toBeTruthy();
expect(issued[3]).toBeTruthy();
// The portal shows the number and offers the PDF.
await page.goto('/basic-training-certificate');
await expect(page.getByText(issued[1], { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: /download pdf/i })).toBeVisible();
// Issued once: a second issue is refused and the number stands.
const [again] = await runDocumentWorkflow(btcId, [{ path: 'issue', expectFailure: true }]);
expect(again).toBeGreaterThanOrEqual(400);
expect(documentStatus(btcId)).toBe('ISSUED');
});
test('the form can be completed in the browser and approved from the backoffice', async ({
@@ -331,7 +375,7 @@ test.describe('seafarer registration', () => {
await expect(page.getByText('Addis Marine Clinic')).toBeVisible();
await page.getByRole('checkbox', { name: /i declare/i }).check();
await page.getByRole('button', { name: /submit registration/i }).click();
await expect(page.getByText(/submitted — still correctable/i)).toBeVisible({
await expect(page.getByText(/with the authority for review/i)).toBeVisible({
timeout: 30_000,
});
expect(statusOf(number)).toBe('SUBMITTED');
@@ -352,8 +396,7 @@ test.describe('seafarer registration', () => {
await expect(page.getByRole('heading', { name: applicant.name })).toBeVisible({
timeout: 30_000,
});
await act(page, /^claim$/i);
await expect(page.getByText('Under Review')).toBeVisible();
// No claim step: the decision is taken straight off the queue.
await act(page, /^approve$/i, /^confirm$/i);
await expect(page.getByText('Approved', { exact: true })).toBeVisible({
timeout: 30_000,
@@ -432,18 +475,21 @@ function seafarerNumberOf(email: string): string | null {
`);
}
/** The licence applications approval opened for this applicant. */
function childrenOf(email: string): string[][] {
/** The Seaman Book / BTC requests opened for this applicant: kind, status, fee, id. */
function documentsOf(email: string): string[][] {
return sql(`
SELECT lt.key, a.status, a.origin
FROM license_applications a
JOIN license_types lt ON lt.id = a.license_type_id
JOIN iam.users u ON u.id = a.applicant_user_id
WHERE u.email = '${email}' AND a.origin = 'AUTO_SEAFARER_APPROVAL'
ORDER BY lt.key
SELECT d.kind, d.status, d.fee_amount, d.id
FROM seafarer_documents d
JOIN iam.users u ON u.id = d.applicant_user_id
WHERE u.email = '${email}'
ORDER BY d.kind::text
`);
}
function documentStatus(documentId: string): string | null {
return sqlValue(`SELECT status FROM seafarer_documents WHERE id = '${documentId}'`);
}
/**
* Fills the draft's answers and evidence directly, so it can be submitted.
*

View File

@@ -56,10 +56,16 @@ export function newApplicant(label: string): Applicant {
export async function signUp(page: Page, applicant: Applicant): Promise<number> {
await page.goto('/signup');
// Signup collects the name in parts now; `applicant.name` is what they join to.
await page.getByLabel('First name').fill(applicant.firstName);
await page.getByLabel('Middle name').fill(applicant.middleName);
await page.getByLabel('Last name').fill(applicant.lastName);
// The form has shipped both as one full-name field and as first/middle/last
// parts; `applicant.name` is what the parts join to, so either is filled.
const fullName = page.getByLabel(/^(full )?name \(english\)$/i);
if (await fullName.isVisible({ timeout: 5_000 }).catch(() => false)) {
await fullName.fill(applicant.name);
} else {
await page.getByLabel('First name').fill(applicant.firstName);
await page.getByLabel('Middle name').fill(applicant.middleName);
await page.getByLabel('Last name').fill(applicant.lastName);
}
await page.getByLabel('Email address').fill(applicant.email);
await page.getByLabel('Username').fill(applicant.username);
await page.getByLabel('Phone number').fill(applicant.phoneNumber);
@@ -70,7 +76,21 @@ export async function signUp(page: Page, applicant: Applicant): Promise<number>
await page.getByRole('checkbox').check();
const offset = logOffset();
await page.getByRole('button', { name: /create account|sign up/i }).click();
const submit = page.getByRole('button', { name: /create account|sign up/i });
await submit.click();
// The first request after the API boots occasionally fails in the browser
// before it reaches the server ("Network error"); the form stays filled, so
// resubmitting is exactly what a person would do.
for (let attempt = 0; attempt < 3; attempt++) {
const failed = page.getByText(/network error/i);
const outcome = await Promise.race([
page.waitForURL(/\/(otp-verify|onboarding|dashboard)/, { timeout: 15_000 }).then(() => 'navigated'),
failed.waitFor({ state: 'visible', timeout: 15_000 }).then(() => 'failed'),
]).catch(() => 'timeout');
if (outcome !== 'failed') break;
await page.waitForTimeout(2_000);
await submit.click();
}
return offset;
}

View File

@@ -110,11 +110,16 @@ export function deleteApplicant(email: string): void {
DELETE FROM attachments WHERE owner_type = 'SEAFARER_REGISTRATION'
AND owner_id IN (SELECT id FROM seafarer_registrations WHERE applicant_user_id = '${userId}');
DELETE FROM seafarer_registrations WHERE applicant_user_id = '${userId}';
DELETE FROM application_payments WHERE document_id IN (SELECT id FROM seafarer_documents WHERE applicant_user_id = '${userId}');
DELETE FROM seafarer_documents WHERE applicant_user_id = '${userId}';
DELETE FROM licenses WHERE holder_user_id = '${userId}';
DELETE FROM license_applications WHERE applicant_user_id = '${userId}';
DELETE FROM profile_operator_types
WHERE profile_id IN (SELECT id FROM profiles WHERE user_id = '${userId}');
DELETE FROM profiles WHERE user_id = '${userId}';
WITH gone AS (
DELETE FROM profiles WHERE user_id = '${userId}' RETURNING address_id
)
DELETE FROM addresses WHERE id IN (SELECT address_id FROM gone WHERE address_id IS NOT NULL);
DELETE FROM iam.notifications WHERE recipient_id = '${userId}';
DELETE FROM iam.user_verifications WHERE user_id = '${userId}';
DELETE FROM iam.user_credentials WHERE user_id = '${userId}';

View File

@@ -198,10 +198,50 @@ export async function runRegistrationWorkflow(
return codes;
}
/** Claim then approve — the whole officer path for a registration. */
/** Approve — the whole officer path for a registration; there is no claim. */
export async function approveRegistration(registrationId: string): Promise<void> {
await runRegistrationWorkflow(registrationId, [
{ path: 'claim' },
{ path: 'approve', data: { remark: 'E2E approval' } },
]);
}
/**
* The Seaman Book / BTC endpoints. `payments/bypass` is the applicant's;
* confirm-payment, schedule-issuance, issue and reject are the officer's.
*/
export async function runDocumentWorkflow(
documentId: string,
steps: WorkflowStep[],
applicant?: { email: string; password: string },
): Promise<number[]> {
const officer = await officerContext();
const isApplicantStep = (path: string) => path.startsWith('payments/');
const needsApplicant = steps.some((step) => isApplicantStep(step.path));
if (needsApplicant && !applicant) {
throw new Error('`payments/*` acts as the applicant — pass their credentials.');
}
const owner = needsApplicant && applicant
? await contextFor('Applicant', applicant)
: null;
const codes: number[] = [];
try {
for (const step of steps) {
const applicantStep = isApplicantStep(step.path);
const base = applicantStep ? 'seafarer-documents' : 'seafarer-document-review';
const api = applicantStep && owner ? owner : officer;
const response = await api.post(`${base}/${documentId}/${step.path}`, {
data: step.data ?? {},
});
codes.push(response.status());
if (!step.expectFailure && !response.ok()) {
throw new Error(
`Step "${step.path}" failed (${response.status()}): ${await response.text()}`,
);
}
}
} finally {
await officer.dispose();
await owner?.dispose();
}
return codes;
}

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

View File

@@ -5,6 +5,7 @@ export * from './lib/features/licensing';
export * from './lib/features/location';
export * from './lib/features/seafarer';
export * from './lib/features/seafarer-registration';
export * from './lib/features/seafarer-document';
export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';

View File

@@ -613,7 +613,8 @@ export interface InitiatePaymentResult {
export interface ApplicationPayment {
id: string;
applicationId: string;
applicationId: string | null;
documentId?: string | null;
paymentIntentId: string;
amount: string;
currency: string;

View File

@@ -0,0 +1,3 @@
export * from './seafarer-document.types';
export * from './seafarer-document.constants';
export * from './seafarer-document-api';

View File

@@ -0,0 +1,131 @@
import { baseApi } from '../../base-api';
import type { ApplicationPayment, InitiatePaymentResult } from '../licensing/licensing.types';
import type {
SeafarerDocument,
SeafarerDocumentDetail,
SeafarerDocumentKind,
SeafarerDocumentRow,
SeafarerDocumentStatus,
} from './seafarer-document.types';
const TAG = 'SeafarerDocument' as const;
const LIST = { type: TAG, id: 'LIST' } as const;
const item = (id: string) => ({ type: TAG, id }) as const;
export interface SeafarerDocumentListFilter {
kind?: SeafarerDocumentKind;
status?: SeafarerDocumentStatus;
search?: string;
take?: number;
skip?: number;
}
/**
* Seaman Book and BTC — own endpoints, not licence applications. Fees settle
* through the same payment gateway, under `/seafarer-documents/:id/payments`.
*/
export const seafarerDocumentApi = baseApi
.enhanceEndpoints({ addTagTypes: [TAG] })
.injectEndpoints({
endpoints: (builder) => ({
// ------------------------------------------------------------ applicant
getMySeafarerDocuments: builder.query<
{ seamanBook: SeafarerDocument | null; btc: SeafarerDocument | null },
void
>({
query: () => ({ url: '/seafarer-documents/mine' }),
providesTags: () => [LIST],
}),
getMySeafarerDocumentDownload: builder.query<{ url: string }, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/download` }),
}),
initiateDocumentPayment: builder.mutation<
InitiatePaymentResult,
{ id: string; provider?: string; platform?: 'web' | 'mobile'; payerAccount?: string }
>({
query: ({ id, ...body }) => ({
url: `/seafarer-documents/${id}/payments/initiate`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
}),
getDocumentPayment: builder.query<ApplicationPayment, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/payments` }),
providesTags: (_r, _e, id) => [item(id)],
}),
bypassDocumentPayment: builder.mutation<{ status: SeafarerDocumentStatus }, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/payments/bypass`, method: 'POST' }),
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
// --------------------------------------------------------------- review
listSeafarerDocuments: builder.query<
{ total: number; items: SeafarerDocumentRow[] },
SeafarerDocumentListFilter
>({
query: (params) => ({ url: '/seafarer-document-review', params }),
providesTags: () => [LIST],
}),
getSeafarerDocumentReview: builder.query<SeafarerDocumentDetail, string>({
query: (id) => ({ url: `/seafarer-document-review/${id}` }),
providesTags: (_r, _e, id) => [item(id)],
}),
getSeafarerDocumentReviewDownload: builder.query<{ url: string }, string>({
query: (id) => ({ url: `/seafarer-document-review/${id}/download` }),
}),
confirmSeafarerDocumentPayment: builder.mutation<SeafarerDocument, string>({
query: (id) => ({ url: `/seafarer-document-review/${id}/confirm-payment`, method: 'POST' }),
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
scheduleSeafarerDocument: builder.mutation<
SeafarerDocument,
{ id: string; scheduledDate: string }
>({
query: ({ id, ...body }) => ({
url: `/seafarer-document-review/${id}/schedule-issuance`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
}),
issueSeafarerDocument: builder.mutation<SeafarerDocument, string>({
query: (id) => ({ url: `/seafarer-document-review/${id}/issue`, method: 'POST' }),
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
rejectSeafarerDocument: builder.mutation<SeafarerDocument, { id: string; reason: string }>({
query: ({ id, ...body }) => ({
url: `/seafarer-document-review/${id}/reject`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
}),
}),
overrideExisting: false,
});
export const {
useGetMySeafarerDocumentsQuery,
useLazyGetMySeafarerDocumentDownloadQuery,
useInitiateDocumentPaymentMutation,
useGetDocumentPaymentQuery,
useBypassDocumentPaymentMutation,
useListSeafarerDocumentsQuery,
useGetSeafarerDocumentReviewQuery,
useLazyGetSeafarerDocumentReviewDownloadQuery,
useConfirmSeafarerDocumentPaymentMutation,
useScheduleSeafarerDocumentMutation,
useIssueSeafarerDocumentMutation,
useRejectSeafarerDocumentMutation,
} = seafarerDocumentApi;

View File

@@ -0,0 +1,28 @@
import type { SeafarerDocumentKind, SeafarerDocumentStatus } from './seafarer-document.types';
export const SEAFARER_DOCUMENT_KIND_LABELS: Record<SeafarerDocumentKind, string> = {
SEAMAN_BOOK: 'Seaman Book',
BTC_BASIC_TRAINING: 'Basic Training Certificate',
};
export const SEAFARER_DOCUMENT_STATUS_LABELS: Record<SeafarerDocumentStatus, string> = {
AWAITING_REGISTRATION: 'Awaiting Registration',
PAYMENT_PENDING: 'Payment Pending',
PAID: 'Paid',
PAYMENT_CONFIRMED: 'Payment Confirmed',
SCHEDULED: 'Pickup Scheduled',
ISSUED: 'Issued',
REJECTED: 'Rejected',
CANCELLED: 'Cancelled',
};
export const SEAFARER_DOCUMENT_STATUS_COLORS: Record<SeafarerDocumentStatus, string> = {
AWAITING_REGISTRATION: 'gray',
PAYMENT_PENDING: 'orange',
PAID: 'blue',
PAYMENT_CONFIRMED: 'blue',
SCHEDULED: 'grape',
ISSUED: 'teal',
REJECTED: 'red',
CANCELLED: 'gray',
};

View File

@@ -0,0 +1,54 @@
import type { ApplicationPayment } from '../licensing/licensing.types';
export type SeafarerDocumentKind = 'SEAMAN_BOOK' | 'BTC_BASIC_TRAINING';
export type SeafarerDocumentStatus =
| 'AWAITING_REGISTRATION'
| 'PAYMENT_PENDING'
| 'PAID'
| 'PAYMENT_CONFIRMED'
| 'SCHEDULED'
| 'ISSUED'
| 'REJECTED'
| 'CANCELLED';
/** A Seaman Book or BTC request — opened by a seafarer registration. */
export interface SeafarerDocument {
id: string;
kind: SeafarerDocumentKind;
requestNumber: string;
applicantUserId: string;
profileId: string | null;
seafarerRegistrationId: string | null;
status: SeafarerDocumentStatus;
feeAmount: number | null;
feeCurrency: string;
submittedAt: string | null;
paidAt: string | null;
paymentReference: string | null;
scheduledIssuanceDate: string | null;
documentNumber: string | null;
issueDate: string | null;
expiryDate: string | null;
documentFileKey: string | null;
issuedAt: string | null;
rejectionReason: string | null;
createdAt: string;
}
export interface SeafarerDocumentApplicant {
name: string;
seafarerNumber: string | null;
registrationNumber: string | null;
registrationId: string | null;
}
export type SeafarerDocumentRow = SeafarerDocument & {
applicant: SeafarerDocumentApplicant | null;
};
export interface SeafarerDocumentDetail {
document: SeafarerDocument;
applicant: SeafarerDocumentApplicant | null;
payment: ApplicationPayment | null;
}

View File

@@ -66,11 +66,6 @@ export const seafarerRegistrationApi = baseApi
providesTags: (_r, _e, id) => [item(id)],
}),
claimSeafarerRegistration: builder.mutation<SeafarerRegistration, string>({
query: (id) => ({ url: `/seafarer-registration-review/${id}/claim`, method: 'POST' }),
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
approveSeafarerRegistration: builder.mutation<
SeafarerRegistration,
{ id: string; remark?: string }
@@ -117,7 +112,6 @@ export const {
useSubmitSeafarerRegistrationMutation,
useListSeafarerRegistrationsQuery,
useGetSeafarerRegistrationReviewQuery,
useClaimSeafarerRegistrationMutation,
useApproveSeafarerRegistrationMutation,
useRejectSeafarerRegistrationMutation,
useRequestSeafarerRegistrationChangesMutation,

View File

@@ -102,7 +102,6 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
RESUBMIT_REQUIRED: 'Corrections Requested',
APPROVED: 'Approved',
REJECTED: 'Rejected',
@@ -111,7 +110,6 @@ export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationSta
export const SEAFARER_REGISTRATION_STATUS_COLORS: Record<SeafarerRegistrationStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'indigo',
RESUBMIT_REQUIRED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',

View File

@@ -3,7 +3,6 @@ import type { SeafarerDepartment } from '../seafarer/seafarer.types';
export type SeafarerRegistrationStatus =
| 'DRAFT'
| 'SUBMITTED'
| 'UNDER_REVIEW'
| 'RESUBMIT_REQUIRED'
| 'APPROVED'
| 'REJECTED';
@@ -55,8 +54,6 @@ export interface SeafarerRegistration extends SeafarerRegistrationAnswers {
profileId: string | null;
status: SeafarerRegistrationStatus;
submittedAt: string | null;
assignedOfficerId: string | null;
claimedAt: string | null;
decidedAt: string | null;
decidedById: string | null;
/** What the officer asked to be fixed, or noted at approval. */