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