mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: add seafarer registration feature with multi-step form
- Implemented SeafarerRegistrationPage component with five-step registration process. - Added API endpoints for seafarer registration including start, save, and submit functionalities. - Created necessary types and constants for seafarer registration. - Updated router to include new registration paths and permissions. - Integrated profile defaults to pre-fill registration fields where applicable. - Added validation and error handling for registration steps. - Enhanced document upload functionality specific to seafarer registration.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
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_REGISTRATION_STATUS_COLORS,
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
displaySeafarerAnswer,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
type SeafarerRegistration,
|
||||
type SeafarerRegistrationStatus,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const STATUS_FILTERS = (Object.keys(SEAFARER_REGISTRATION_STATUS_LABELS) as SeafarerRegistrationStatus[])
|
||||
.filter((s) => s !== 'DRAFT')
|
||||
.map((value) => ({ value, label: SEAFARER_REGISTRATION_STATUS_LABELS[value] }));
|
||||
|
||||
export function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middleName' | 'lastName'>): string {
|
||||
return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—';
|
||||
}
|
||||
|
||||
/** Submitted seafarer registrations, oldest first — click a row to review it. */
|
||||
export function SeafarerRegistrationQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const [status, setStatus] = useState<SeafarerRegistrationStatus | 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 } = useListSeafarerRegistrationsQuery({
|
||||
status: status ?? undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
take: pageSize,
|
||||
skip: page * pageSize,
|
||||
});
|
||||
|
||||
const columns: AdvancedColumn<SeafarerRegistration>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Registration №',
|
||||
accessorKey: 'registrationNumber',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" ff="monospace">
|
||||
{row.original.registrationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Applicant',
|
||||
accessorKey: 'lastName',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{applicantName(row.original)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.nationalIdNumber ?? '—'}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Department',
|
||||
accessorKey: 'department',
|
||||
cell: ({ row }) => <Text size="sm">{displaySeafarerAnswer('department', row.original.department)}</Text>,
|
||||
},
|
||||
{
|
||||
header: 'Submitted',
|
||||
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_REGISTRATION_STATUS_COLORS[row.original.status]}>
|
||||
{SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
[showDate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
Seafarer Registration Queue
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and
|
||||
BTC applications.
|
||||
</Text>
|
||||
<Group mb="md" gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search number, name or ID…"
|
||||
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 SeafarerRegistrationStatus | null);
|
||||
setPage(0);
|
||||
}}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
tableName="Seafarer registrations"
|
||||
itemCount={data?.total ?? 0}
|
||||
pageIndex={page}
|
||||
onPageChange={setPage}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(0);
|
||||
}}
|
||||
refresh={refetch}
|
||||
isLoading={isLoading || isFetching}
|
||||
emptyText="No registrations match."
|
||||
onRowClick={(row) => navigate(`/seafarer-registrations/${row.id}`)}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeafarerRegistrationQueuePage;
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconInfoCircle } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||
SEAFARER_REGISTRATION_SECTIONS,
|
||||
SEAFARER_REGISTRATION_STATUS_COLORS,
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
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';
|
||||
|
||||
const DECISION_COPY: Record<Decision, { title: string; label: string; color: string; required: boolean }> = {
|
||||
approve: { title: 'Approve registration', label: 'Remark (optional)', color: 'teal', required: false },
|
||||
changes: { title: 'Request corrections', label: 'What must the applicant fix?', color: 'orange', required: true },
|
||||
reject: { title: 'Reject registration', label: 'Reason (shown to the applicant)', color: 'red', required: true },
|
||||
};
|
||||
|
||||
/** One registration: every answer, every upload, and the officer's actions. */
|
||||
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();
|
||||
|
||||
const [decision, setDecision] = useState<Decision | null>(null);
|
||||
const [text, setText] = 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 registration.')}
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const { registration, attachments } = data;
|
||||
const mine = !registration.assignedOfficerId || registration.assignedOfficerId === me?.id;
|
||||
const canDecide = registration.status === 'UNDER_REVIEW' && mine;
|
||||
const busy = approving || rejecting || requesting;
|
||||
|
||||
async function run(action: () => Promise<unknown>, done: string) {
|
||||
try {
|
||||
await action();
|
||||
notify.success(done);
|
||||
setDecision(null);
|
||||
setText('');
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not record the decision'));
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDecision() {
|
||||
const remark = text.trim();
|
||||
if (decision === 'approve') {
|
||||
run(() => approve({ id, remark: remark || undefined }).unwrap(), 'Registration approved — seafarer numbered.');
|
||||
} else if (decision === 'changes') {
|
||||
run(() => requestChanges({ id, remark }).unwrap(), 'Sent back for corrections.');
|
||||
} else if (decision === 'reject') {
|
||||
run(() => reject({ id, reason: remark }).unwrap(), 'Registration rejected.');
|
||||
}
|
||||
}
|
||||
|
||||
const slots = SEAFARER_REGISTRATION_DOCUMENTS.filter(
|
||||
(d) => d.required !== 'passport' || registration.passportNumber,
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/seafarer-registrations')} mb="xs">
|
||||
Back to queue
|
||||
</Button>
|
||||
<Group justify="space-between" align="flex-start" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{applicantName(registration)}</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Text size="sm" c="dimmed" ff="monospace">
|
||||
{registration.registrationNumber}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
|
||||
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
||||
</Badge>
|
||||
{registration.seafarerNumber && (
|
||||
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
{registration.seafarerNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</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>
|
||||
<Button variant="default" onClick={() => setDecision('changes')}>
|
||||
Request corrections
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REJECT_APPLICATION]} hideOnly>
|
||||
<Button color="red" variant="light" onClick={() => setDecision('reject')}>
|
||||
Reject
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||
<Button color="teal" onClick={() => setDecision('approve')}>
|
||||
Approve
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
</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}
|
||||
</Alert>
|
||||
)}
|
||||
{registration.status === 'REJECTED' && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
|
||||
{registration.rejectionReason}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stack gap="md">
|
||||
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
|
||||
<div key={section.key}>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
{section.title}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
{section.fields
|
||||
.filter((f) => f !== 'passportExpiry' || registration.passportNumber)
|
||||
.map((field) => (
|
||||
<Table.Tr key={field}>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{SEAFARER_REGISTRATION_FIELD_LABELS[field]}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{displaySeafarerAnswer(field, registration[field])}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Documents
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
{slots.map((slot) => {
|
||||
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
|
||||
const required = slot.required === 'passport' ? true : slot.required;
|
||||
return (
|
||||
<Table.Tr key={slot.key}>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{slot.name}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{file ? (
|
||||
<Group gap="xs">
|
||||
<Text size="sm">{file.originalName}</Text>
|
||||
{file.url && (
|
||||
<Button size="compact-xs" variant="light" component="a" href={file.url} target="_blank" rel="noopener noreferrer">
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c={required ? 'red' : 'dimmed'}>
|
||||
{required ? 'Missing' : '—'}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={decision !== null}
|
||||
onClose={() => setDecision(null)}
|
||||
title={decision ? DECISION_COPY[decision].title : ''}
|
||||
centered
|
||||
>
|
||||
{decision && (
|
||||
<Stack>
|
||||
<Textarea
|
||||
label={DECISION_COPY[decision].label}
|
||||
required={DECISION_COPY[decision].required}
|
||||
minRows={3}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setDecision(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={DECISION_COPY[decision].color}
|
||||
loading={busy}
|
||||
disabled={DECISION_COPY[decision].required && text.trim().length < 3}
|
||||
onClick={confirmDecision}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeafarerRegistrationReviewPage;
|
||||
@@ -95,7 +95,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
label: 'nav.groupSeafarer',
|
||||
items: [
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||
{ to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
|
||||
{ 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 },
|
||||
|
||||
@@ -25,6 +25,8 @@ import { ApplicationReviewPage } from '../features/applications/pages/Applicatio
|
||||
import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
||||
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 { QuestionPage } from '../features/question/pages/QuestionPage';
|
||||
import { ExamPage } from '../features/exam/pages/ExamPage';
|
||||
@@ -86,6 +88,10 @@ const router = createBrowserRouter([
|
||||
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
||||
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
||||
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||
// Seafarer registration is not a licence: own queue, own review.
|
||||
{ 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 /> },
|
||||
{ path: 'seaman-book-queue', element: guard(APPLICATION_QUEUE, <SeamanBookQueuePage />) },
|
||||
{ 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 />) },
|
||||
|
||||
@@ -6,50 +6,31 @@ import {
|
||||
verifyOtpIfPrompted,
|
||||
} from './support/applicant';
|
||||
import { deleteApplicant, sql, sqlValue } from './support/db';
|
||||
import {
|
||||
approveRegistration,
|
||||
resolveOpenRemarks,
|
||||
runWorkflow,
|
||||
} from './support/workflow';
|
||||
import { approveRegistration, runRegistrationWorkflow } from './support/workflow';
|
||||
import { act, logInAsOfficer, openInQueue } from './support/officer';
|
||||
|
||||
/**
|
||||
* Seafarer registration, applicant through to approval.
|
||||
*
|
||||
* The registration is the one service whose approval has consequences beyond
|
||||
* its own row: it stamps a permanent number on the profile, activates the
|
||||
* seafarer record, and opens the Seaman Book and BTC applications on the
|
||||
* applicant's behalf. Those effects only fire at final approval, so nothing
|
||||
* short of driving a registration into an officer's hands exercises them.
|
||||
*
|
||||
* The workflow is deliberately shorter than a licence's — no evaluation stage,
|
||||
* no inspection — so which actions an officer is *refused* is as much the
|
||||
* subject here as which ones work.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fills the profile the seafarer wizard prefills its Identity Details step
|
||||
* from.
|
||||
*
|
||||
* No longer a precondition for reaching the wizard — that redirect is gone and
|
||||
* 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.
|
||||
*
|
||||
* They are split across two tabs, and every tab's panel is in the DOM whether
|
||||
* or not it is showing — so each one has to be selected before its inputs can
|
||||
* be filled, and `PROFILE_FIELD_SECTION` in the auth lib is the map of which
|
||||
* field lives where.
|
||||
* 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.
|
||||
*/
|
||||
async function completeProfile(
|
||||
page: Page,
|
||||
applicant: Applicant,
|
||||
): Promise<void> {
|
||||
async function completeProfile(page: Page, applicant: Applicant): Promise<void> {
|
||||
await page.goto('/profile');
|
||||
|
||||
await openTab(page, 'Profile');
|
||||
// The account's own name parts, not invented ones: the Maritime tab refuses
|
||||
// to save when they do not join to the name on the Personal tab, and it
|
||||
// refuses by returning early — no request, no field error, so the failure
|
||||
// surfaced only as "save produced no request".
|
||||
await page.getByLabel('First Name').fill(applicant.firstName);
|
||||
await page.getByLabel('Middle Name').fill(applicant.middleName);
|
||||
await page.getByLabel('Last Name').fill(applicant.lastName);
|
||||
@@ -60,88 +41,42 @@ async function completeProfile(
|
||||
await save(page);
|
||||
|
||||
await openTab(page, 'Address');
|
||||
// Matched on the option's label, not its stored value: the select shows
|
||||
// "National Id" and submits `NID`, so `/^NID$/` matched no option at all.
|
||||
await pick(page, 'ID Type', /^national id$/i);
|
||||
await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||
// A country select, not a free-text field.
|
||||
await pick(page, 'Nationality', /ethiopia/i);
|
||||
// Primary Phone is deliberately not filled: it is `readOnly` here and already
|
||||
// carries the account's number ("From your account, edit it in the Personal
|
||||
// tab"), so `addressSchema`'s Ethiopian-format rule is already satisfied and a
|
||||
// fill would only fail against a read-only input.
|
||||
await save(page);
|
||||
}
|
||||
|
||||
/** Selects a profile tab and waits for its panel to be the visible one. */
|
||||
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,
|
||||
});
|
||||
await expect(page.getByRole('tabpanel', { name })).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a value from a Mantine select.
|
||||
*
|
||||
* The label is bound to both the input and the listbox it opens, so matching
|
||||
* by label alone is ambiguous once the dropdown is showing — the textbox role
|
||||
* names the control itself.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the date of birth through the picker's own UI.
|
||||
*
|
||||
* `AmharicDatePicker` is a controlled component: it reports changes through
|
||||
* `onChange`, which is what writes the value into react-hook-form. Setting the
|
||||
* input's `value` natively bypasses that entirely — the field stays empty as
|
||||
* far as zod is concerned, and the form silently refuses to submit.
|
||||
*
|
||||
* So the calendar is actually driven: open it, pick the year and month from
|
||||
* the caption dropdowns, then click the day.
|
||||
*/
|
||||
/** Drives the AmharicDatePicker's own UI — a native `value` write bypasses `onChange`. */
|
||||
async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||
const [year, month, day] = iso.split('-').map(Number);
|
||||
|
||||
await page.getByRole('textbox', { name: label }).click();
|
||||
const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||
await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// `captionLayout="dropdown"` renders native selects for month and year.
|
||||
await calendar.locator('select').last().selectOption(String(year));
|
||||
await calendar
|
||||
.locator('select')
|
||||
.first()
|
||||
.selectOption({ index: month - 1 });
|
||||
|
||||
// Each day is a button whose accessible name is the full date
|
||||
// ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
||||
// number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
||||
// or 21. Resolved after the dropdowns settle, since changing year or month
|
||||
// re-renders the grid.
|
||||
await calendar.locator('select').first().selectOption({ index: month - 1 });
|
||||
const cell = calendar
|
||||
.getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||
.first();
|
||||
await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
await cell.click();
|
||||
|
||||
await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
// The picker writes through `onChange`; if that did not land, zod still sees
|
||||
// an empty field and the failure would surface later as a refused submit.
|
||||
await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function save(page: Page): Promise<void> {
|
||||
// Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
||||
// tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
||||
// route's own spelling. Any successful write from this screen is the signal.
|
||||
const saved = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() !== 'GET' &&
|
||||
@@ -150,42 +85,20 @@ async function save(page: Page): Promise<void> {
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await page.getByRole('button', { name: /save/i }).first().click();
|
||||
|
||||
try {
|
||||
await saved;
|
||||
} catch (cause) {
|
||||
// A zod-blocked submit fires no request at all, so the bare timeout says
|
||||
// only "no response" — which reads as a backend fault rather than a form
|
||||
// that refused to submit. Surface the field errors instead.
|
||||
// Field errors only. `[role="alert"]` also matches Mantine's `<Alert>`, and
|
||||
// the profile page renders an informational seafarer banner as one — which
|
||||
// got reported as "validation errors: Seafarer registration asks for these
|
||||
// details…", pointing at a form that was in fact filled in correctly.
|
||||
const messages = await page
|
||||
.locator('.mantine-InputWrapper-error')
|
||||
.allTextContents();
|
||||
const messages = await page.locator('.mantine-InputWrapper-error').allTextContents();
|
||||
throw new Error(
|
||||
messages.length
|
||||
? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||
: // No field error either, so the form was valid and something else
|
||||
// refused: `onSaveProfile` early-returns when the profile name does
|
||||
// not match the account name, and notifies rather than marking a
|
||||
// field.
|
||||
'Save produced no request and reported no field error — check for a rejected notification (e.g. the profile/account name match).',
|
||||
: 'Save produced no request and reported no field error.',
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs up, declares seafarer operations, and fills the profile.
|
||||
*
|
||||
* Declaring seafarer now lands on the registration wizard, not `/profile` — the
|
||||
* wizard collects the identity itself. The profile is still filled here because
|
||||
* these tests are about the registration workflow, and a profile with a name and
|
||||
* an address is what the approval's completion effect writes onto; `/profile` is
|
||||
* navigated to directly rather than waited for as a redirect.
|
||||
*/
|
||||
/** Signs up, declares seafarer operations, and fills the profile. */
|
||||
async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
const offset = await signUp(page, applicant);
|
||||
await verifyOtpIfPrompted(page, offset);
|
||||
@@ -195,11 +108,8 @@ async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
.first()
|
||||
.check();
|
||||
await page.getByRole('button', { name: /save operations/i }).click();
|
||||
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page).toHaveURL(/\/seafarer-registration/, { timeout: 30_000 });
|
||||
await page.goto('/profile');
|
||||
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
await completeProfile(page, applicant);
|
||||
}
|
||||
|
||||
@@ -214,7 +124,7 @@ test.describe('seafarer registration', () => {
|
||||
deleteApplicant(applicant.email);
|
||||
});
|
||||
|
||||
test('selecting seafarer opens the registration wizard', async ({ page }) => {
|
||||
test('selecting seafarer opens the registration form', async ({ page }) => {
|
||||
const offset = await signUp(page, applicant);
|
||||
await verifyOtpIfPrompted(page, offset);
|
||||
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
@@ -224,215 +134,256 @@ test.describe('seafarer registration', () => {
|
||||
.check();
|
||||
await page.getByRole('button', { name: /save operations/i }).click();
|
||||
|
||||
// Straight to the form they came for. The wizard collects the identity
|
||||
// itself (Identity Details), so a brand-new account with an empty profile
|
||||
// is a thing it fills rather than a reason to be sent to /profile first.
|
||||
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Straight to the form they came for — its own page, not the licence wizard.
|
||||
await expect(page).toHaveURL(/\/seafarer-registration$/, { timeout: 30_000 });
|
||||
await expect(page.getByRole('heading', { name: /seafarer registration/i })).toBeVisible();
|
||||
|
||||
// The short link lands in the same place.
|
||||
await page.goto('/seafarer-registration');
|
||||
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
// The old licence-wizard link lands in the same place.
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
await expect(page).toHaveURL(/\/seafarer-registration$/, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||
test('opening the form creates the draft up front', async ({ page }) => {
|
||||
await readyApplicant(page, applicant);
|
||||
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
await page.goto('/seafarer-registration');
|
||||
|
||||
// The draft exists before anything is filled in, so uploads have an owner
|
||||
// and closing the browser mid-wizard loses nothing.
|
||||
const number = await waitForApplication(applicant.email);
|
||||
// and closing the browser mid-form loses nothing.
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
expect(number).toMatch(/^SFR/);
|
||||
expect(statusOf(number)).toBe('DRAFT');
|
||||
|
||||
// Prefilled from the profile the applicant just completed.
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(applicant.firstName);
|
||||
});
|
||||
|
||||
test('a registration never reaches evaluation or inspection', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('an incomplete registration is refused with what is missing', async ({ page }) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
const number = await waitForApplication(applicant.email);
|
||||
const id = idOf(number);
|
||||
await page.goto('/seafarer-registration');
|
||||
const id = idOf(await waitForRegistration(applicant.email));
|
||||
|
||||
await submit(id, applicant);
|
||||
await runWorkflow(id, [{ path: 'claim' }]);
|
||||
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
|
||||
// The licence course's middle stages have nothing to hold in a
|
||||
// registration, and the transition table is the authority regardless of
|
||||
// which endpoint is called.
|
||||
const refused = await runWorkflow(id, [
|
||||
{ path: 'complete-review', expectFailure: true },
|
||||
{ path: 'approve-documents', expectFailure: true },
|
||||
{ path: 'record-inspection', expectFailure: true },
|
||||
]);
|
||||
expect(refused.every((code) => code >= 400)).toBe(true);
|
||||
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
const [code] = await runRegistrationWorkflow(
|
||||
id,
|
||||
[{ path: 'submit', expectFailure: true }],
|
||||
applicant,
|
||||
);
|
||||
expect(code).toBe(400);
|
||||
});
|
||||
|
||||
test('an officer can return a registration for correction and take it back', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
const number = await waitForApplication(applicant.email);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id, applicant);
|
||||
await runWorkflow(id, [
|
||||
expect(statusOf(number)).toBe('SUBMITTED');
|
||||
|
||||
await runRegistrationWorkflow(id, [
|
||||
{ path: 'claim' },
|
||||
{
|
||||
path: 'request-adjustment',
|
||||
// `RequestAdjustmentDto` takes `items`, each naming what to fix and
|
||||
// where — a bare `remarks: [{ message }]` is refused with "items should
|
||||
// not be empty", which reads as an empty request rather than a wrongly
|
||||
// shaped one.
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
targetType: 'FORM_SECTION',
|
||||
targetKey: 'medicalCertificate',
|
||||
remark: 'Medical certificate is illegible.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ path: 'request-changes', data: { remark: 'Medical certificate is illegible.' } },
|
||||
]);
|
||||
expect(statusOf(number)).toBe('RESUBMIT_REQUIRED');
|
||||
|
||||
// Every flagged item has to be ticked off first: `resubmit` refuses while
|
||||
// any remark is open (`unresolved_remarks`), which is what stops an
|
||||
// applicant returning the same form untouched.
|
||||
await resolveOpenRemarks(id, openRemarkIds(number), applicant);
|
||||
|
||||
// A resubmission returns to review directly — a registration has no
|
||||
// earlier stage to fall back to.
|
||||
await runWorkflow(id, [{ path: 'resubmit' }], applicant);
|
||||
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
});
|
||||
|
||||
test('an officer can hold and resume a registration', async ({ page }) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
const number = await waitForApplication(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id, applicant);
|
||||
await runWorkflow(id, [
|
||||
{ path: 'claim' },
|
||||
{ path: 'hold', data: { reason: 'Awaiting confirmation from the clinic.' } },
|
||||
// Nothing can be decided while it is with the applicant.
|
||||
const [refused] = await runRegistrationWorkflow(id, [
|
||||
{ path: 'approve', expectFailure: true },
|
||||
]);
|
||||
expect(statusOf(number)).toBe('ON_HOLD');
|
||||
expect(refused).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// Resume restores whatever it was held from, read back from history.
|
||||
await runWorkflow(id, [{ path: 'resume' }]);
|
||||
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
// A resubmission returns to the queue.
|
||||
await runRegistrationWorkflow(id, [{ path: 'submit' }], applicant);
|
||||
expect(statusOf(number)).toBe('SUBMITTED');
|
||||
});
|
||||
|
||||
test('an officer can reject a registration with a reason', async ({ page }) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
const number = await waitForApplication(applicant.email);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id, applicant);
|
||||
await runWorkflow(id, [
|
||||
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, and the children submit
|
||||
// opened stay drafts — never filed, never billed, nothing an officer sees.
|
||||
// A rejection is terminal: nothing is numbered, nothing is opened.
|
||||
expect(seafarerNumberOf(applicant.email)).toBeNull();
|
||||
expect(childrenOf(number).every((r) => r[1] === 'DRAFT')).toBe(true);
|
||||
expect(childrenOf(applicant.email)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('approval numbers the profile and opens both child applications', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
const number = await waitForApplication(applicant.email);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id, applicant);
|
||||
await approveRegistration(id);
|
||||
|
||||
expect(statusOf(number)).toBe('COMPLETED');
|
||||
expect(statusOf(number)).toBe('APPROVED');
|
||||
|
||||
const profile = sql(`
|
||||
SELECT p.seafarer_number, p.seafarer_status
|
||||
SELECT p.seafarer_number, p.seafarer_status, p.seafarer_department
|
||||
FROM profiles p
|
||||
JOIN iam.users u ON u.id = p.user_id
|
||||
WHERE u.email = '${applicant.email}'
|
||||
`);
|
||||
expect(profile[0][0]).toBeTruthy();
|
||||
expect(profile[0][1]).toBe('ACTIVE');
|
||||
expect(profile[0][2]).toBe('DECK');
|
||||
|
||||
// The medical details become a verified certificate on the profile.
|
||||
expect(
|
||||
sqlValue(`
|
||||
SELECT m.status FROM medical_certificates m
|
||||
JOIN profiles p ON p.id = m.profile_id
|
||||
JOIN iam.users u ON u.id = p.user_id
|
||||
WHERE u.email = '${applicant.email}'
|
||||
`),
|
||||
).toBe('VERIFIED');
|
||||
|
||||
// The applicant is not made to apply twice more for the documents that
|
||||
// prove what they have just been told. Both were opened as drafts when the
|
||||
// registration was submitted; approval is what puts them in flight — the
|
||||
// BTC straight to payment, the Seaman Book into the queue for the TRB
|
||||
// inspection it still owes.
|
||||
const children = childrenOf(number);
|
||||
// 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', 'SUBMITTED'],
|
||||
['SEAMAN_BOOK', 'PAYMENT_PENDING'],
|
||||
]);
|
||||
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');
|
||||
await expect(page.getByText(/you are a registered seafarer/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('a re-fired approval renumbers nobody and opens no second pair', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
const number = await waitForApplication(applicant.email);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id, applicant);
|
||||
await approveRegistration(id);
|
||||
const first = seafarerNumberOf(applicant.email);
|
||||
|
||||
// Approving again must be a no-op, not a second number and a second bill.
|
||||
await runWorkflow(id, [{ path: 'final-approve', expectFailure: true }]);
|
||||
|
||||
const [code] = await runRegistrationWorkflow(id, [
|
||||
{ path: 'approve', expectFailure: true },
|
||||
]);
|
||||
expect(code).toBeGreaterThanOrEqual(400);
|
||||
expect(seafarerNumberOf(applicant.email)).toBe(first);
|
||||
expect(childrenOf(number)).toHaveLength(2);
|
||||
expect(childrenOf(applicant.email)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('the form can be completed in the browser and approved from the backoffice', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
// Uploads need object storage, which this suite does not stand up; the
|
||||
// evidence rows go in directly and the page is reopened so it sees them.
|
||||
insertDocuments(id);
|
||||
await page.reload();
|
||||
|
||||
// Step 1 — Identity Details: prefilled from the profile, nothing to type.
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(applicant.firstName);
|
||||
await expect(page.getByLabel('National ID (Fayda) Number')).toHaveValue('FYD1234567890');
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 2 — Applicant Details.
|
||||
await page.getByLabel('Place of Birth').fill('Addis Ababa');
|
||||
await pick(page, 'Department', /deck/i);
|
||||
await pick(page, 'City', /addis ababa/i);
|
||||
await pick(page, 'Sub-City', /arada/i);
|
||||
await pick(page, 'Hair Colour', /black/i);
|
||||
await pick(page, 'Eye Colour', /brown/i);
|
||||
await page.getByLabel('Height (cm)').fill('172');
|
||||
await page.getByLabel('Weight (kg)').fill('68');
|
||||
await page.getByLabel('Certificate Number').fill('MED-2026-001');
|
||||
await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic');
|
||||
await pickDate(page, 'Issue Date', '2026-01-15');
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 3 — Emergency Contact.
|
||||
await page.getByLabel('Full Name').fill('Almaz Tesfaye');
|
||||
await page.getByLabel('Relationship').fill('Sister');
|
||||
await page.getByLabel('Phone Number').fill('+251911222333');
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 4 — Documents: all four required slots show as uploaded.
|
||||
await expect(page.getByText('uploaded')).toHaveCount(4);
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 5 — Review: the answers typed above, then the declaration.
|
||||
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({
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(statusOf(number)).toBe('SUBMITTED');
|
||||
|
||||
// What was typed is what was stored — typed columns, no form blob.
|
||||
const stored = sql(`
|
||||
SELECT place_of_birth, department, hair_color, height_cm, medical_issue_date,
|
||||
emergency_contact_name
|
||||
FROM seafarer_registrations WHERE id = '${id}'
|
||||
`)[0];
|
||||
expect(stored).toEqual([
|
||||
'Addis Ababa', 'DECK', 'BLACK', '172.0', '2026-01-15', 'Almaz Tesfaye',
|
||||
]);
|
||||
|
||||
// The officer's side, through its own queue and review screen.
|
||||
await logInAsOfficer(page);
|
||||
await openInQueue(page, number);
|
||||
await expect(page.getByRole('heading', { name: applicant.name })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await act(page, /^claim$/i);
|
||||
await expect(page.getByText('Under Review')).toBeVisible();
|
||||
await act(page, /^approve$/i, /^confirm$/i);
|
||||
await expect(page.getByText('Approved', { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(statusOf(number)).toBe('APPROVED');
|
||||
expect(seafarerNumberOf(applicant.email)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a registered seafarer cannot start a second registration', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
const number = await waitForApplication(applicant.email);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
|
||||
await submit(idOf(number), applicant);
|
||||
await approveRegistration(idOf(number));
|
||||
|
||||
// The number is permanent and the service is not renewable, so the portal
|
||||
// stops offering it rather than letting them file a second registration an
|
||||
// officer would review for no outcome.
|
||||
// "Start" returns the approved registration rather than opening another.
|
||||
await runRegistrationWorkflow(idOf(number), [], applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
await expect(page).not.toHaveURL(
|
||||
/\/licensing\/SEAFARER_REGISTRATION\/apply/,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await expect(page.getByText(/you are a registered seafarer/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(
|
||||
sqlValue(`
|
||||
SELECT count(*) 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 lt.key = 'SEAFARER_REGISTRATION'
|
||||
AND u.email = '${applicant.email}'
|
||||
SELECT count(*) FROM seafarer_registrations r
|
||||
JOIN iam.users u ON u.id = r.applicant_user_id
|
||||
WHERE u.email = '${applicant.email}'
|
||||
`),
|
||||
).toBe('1');
|
||||
});
|
||||
@@ -440,20 +391,16 @@ test.describe('seafarer registration', () => {
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
/** Waits for the draft the wizard creates on open, and returns its number. */
|
||||
async function waitForApplication(
|
||||
email: string,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<string> {
|
||||
/** Waits for the draft the form creates on open, and returns its number. */
|
||||
async function waitForRegistration(email: string, timeoutMs = 30_000): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const found = sqlValue(`
|
||||
SELECT a.application_number
|
||||
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 lt.key = 'SEAFARER_REGISTRATION' AND u.email = '${email}'
|
||||
ORDER BY a.created_at DESC LIMIT 1
|
||||
SELECT r.registration_number
|
||||
FROM seafarer_registrations r
|
||||
JOIN iam.users u ON u.id = r.applicant_user_id
|
||||
WHERE u.email = '${email}'
|
||||
ORDER BY r.created_at DESC LIMIT 1
|
||||
`);
|
||||
if (found) return found;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
@@ -461,19 +408,19 @@ async function waitForApplication(
|
||||
throw new Error(`No seafarer registration appeared for ${email}`);
|
||||
}
|
||||
|
||||
function idOf(applicationNumber: string): string {
|
||||
function idOf(registrationNumber: string): string {
|
||||
const id = sqlValue(`
|
||||
SELECT id FROM license_applications
|
||||
WHERE application_number = '${applicationNumber}'
|
||||
SELECT id FROM seafarer_registrations
|
||||
WHERE registration_number = '${registrationNumber}'
|
||||
`);
|
||||
if (!id) throw new Error(`No application ${applicationNumber}`);
|
||||
if (!id) throw new Error(`No registration ${registrationNumber}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
function statusOf(applicationNumber: string): string | null {
|
||||
function statusOf(registrationNumber: string): string | null {
|
||||
return sqlValue(`
|
||||
SELECT status FROM license_applications
|
||||
WHERE application_number = '${applicationNumber}'
|
||||
SELECT status FROM seafarer_registrations
|
||||
WHERE registration_number = '${registrationNumber}'
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -485,26 +432,14 @@ function seafarerNumberOf(email: string): string | null {
|
||||
`);
|
||||
}
|
||||
|
||||
/** Ids of the remarks still open on the current adjustment round. */
|
||||
function openRemarkIds(applicationNumber: string): string[] {
|
||||
return sql(`
|
||||
SELECT r.id FROM application_remarks r
|
||||
JOIN license_applications a ON a.id = r.application_id
|
||||
WHERE a.application_number = '${applicationNumber}'
|
||||
AND r.is_resolved = false
|
||||
AND r.round_number = a.adjustment_round
|
||||
`).map((row) => row[0]);
|
||||
}
|
||||
|
||||
function childrenOf(applicationNumber: string): string[][] {
|
||||
/** The licence applications approval opened for this applicant. */
|
||||
function childrenOf(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
|
||||
WHERE a.parent_application_id = (
|
||||
SELECT id FROM license_applications
|
||||
WHERE application_number = '${applicationNumber}'
|
||||
)
|
||||
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
|
||||
`);
|
||||
}
|
||||
@@ -512,22 +447,17 @@ function childrenOf(applicationNumber: string): string[][] {
|
||||
/**
|
||||
* Fills the draft's answers and evidence directly, so it can be submitted.
|
||||
*
|
||||
* These tests are about the workflow and its approval effects, not the wizard's
|
||||
* fields — but `submit` validates the whole form and every required document, so
|
||||
* an unfilled draft cannot reach the workflow at all. Driving six wizard steps
|
||||
* and four uploads in each test would make them slow tests of the form instead.
|
||||
*
|
||||
* So the answers go in as one `form_data` write and the evidence as attachment
|
||||
* rows. Deliberately not through MinIO: `getSuppliedDocumentKeys` joins
|
||||
* attachments to their files and counts document keys, and nothing at submission
|
||||
* reads a file's bytes — a row with a storage key is exactly as complete as an
|
||||
* upload, without requiring object storage to be reachable.
|
||||
*
|
||||
* Values mirror the seeded schema (`seafarer-registration.seed-data.ts`); a
|
||||
* required field added there fails these with `application_incomplete`, naming
|
||||
* the field.
|
||||
* These tests are about the workflow and its approval effects, not the form's
|
||||
* fields. The answers go in as one UPDATE and the evidence as attachment rows
|
||||
* — a row with a storage key is exactly as complete as an upload to the
|
||||
* submission check, without requiring object storage to be reachable.
|
||||
*/
|
||||
function fillForSubmission(applicationId: string): void {
|
||||
function fillForSubmission(registrationId: string): void {
|
||||
fillAnswers(registrationId);
|
||||
insertDocuments(registrationId);
|
||||
}
|
||||
|
||||
function fillAnswers(registrationId: string): void {
|
||||
const locationId = sqlValue(`
|
||||
SELECT l.id FROM iam.locations l
|
||||
JOIN iam.location_types lt ON lt.id = l.location_type_id
|
||||
@@ -537,54 +467,30 @@ function fillForSubmission(applicationId: string): void {
|
||||
throw new Error('No SUBCITY location seeded — run the location seed.');
|
||||
}
|
||||
|
||||
const formData = JSON.stringify({
|
||||
profileSummary: {
|
||||
firstName: 'Dawit',
|
||||
middleName: 'Bekele',
|
||||
lastName: 'Tesfaye',
|
||||
gender: 'MALE',
|
||||
dateOfBirth: '1995-04-12',
|
||||
maritalStatus: 'SINGLE',
|
||||
nationality: 'Ethiopian',
|
||||
nationalIdNumber: 'FYD1234567890',
|
||||
},
|
||||
identity: { placeOfBirth: 'Addis Ababa', department: 'DECK' },
|
||||
address: { locationId, permanentAddress: 'Bole, Addis Ababa' },
|
||||
emergencyContact: {
|
||||
name: 'Almaz Tesfaye',
|
||||
relationship: 'Sister',
|
||||
phoneNumber: '+251911222333',
|
||||
},
|
||||
physicalCharacteristics: {
|
||||
hairColor: 'BLACK',
|
||||
eyeColor: 'BROWN',
|
||||
heightCm: 172,
|
||||
weightKg: 68,
|
||||
bloodType: 'O_POSITIVE',
|
||||
},
|
||||
medicalCertificate: {
|
||||
certificateNumber: 'MED-2026-001',
|
||||
issuerName: 'Addis Marine Clinic',
|
||||
issueDate: '2026-01-15',
|
||||
},
|
||||
declaration: { accepted: true },
|
||||
}).replace(/'/g, "''");
|
||||
|
||||
const documentKeys = [
|
||||
'photo',
|
||||
'nationalId',
|
||||
'medical_certificate',
|
||||
'basic_training_evidence',
|
||||
];
|
||||
|
||||
sql(`
|
||||
UPDATE license_applications
|
||||
SET form_data = '${formData}'::jsonb
|
||||
WHERE id = '${applicationId}';
|
||||
UPDATE seafarer_registrations SET
|
||||
first_name = 'Dawit', middle_name = 'Bekele', last_name = 'Tesfaye',
|
||||
gender = 'MALE', date_of_birth = '1995-04-12', marital_status = 'SINGLE',
|
||||
nationality = 'Ethiopian', national_id_number = 'FYD1234567890',
|
||||
place_of_birth = 'Addis Ababa', department = 'DECK',
|
||||
location_id = '${locationId}', permanent_address = 'Bole, Addis Ababa',
|
||||
emergency_contact_name = 'Almaz Tesfaye', emergency_contact_relationship = 'Sister',
|
||||
emergency_contact_phone = '+251911222333',
|
||||
hair_color = 'BLACK', eye_color = 'BROWN', height_cm = 172, weight_kg = 68,
|
||||
blood_type = 'O_POSITIVE',
|
||||
medical_certificate_number = 'MED-2026-001', medical_issuer_name = 'Addis Marine Clinic',
|
||||
medical_issue_date = '2026-01-15', declaration_accepted = true
|
||||
WHERE id = '${registrationId}';
|
||||
`);
|
||||
}
|
||||
|
||||
/** The four required evidence rows, as attachment rows with a storage key. */
|
||||
function insertDocuments(registrationId: string): void {
|
||||
const documentKeys = ['photo', 'nationalId', 'medical_certificate', 'basic_training_evidence'];
|
||||
sql(`
|
||||
WITH inserted AS (
|
||||
INSERT INTO attachments (owner_type, owner_id, document_key, valid_from, valid_to)
|
||||
SELECT 'APPLICATION', '${applicationId}', key, CURRENT_DATE, CURRENT_DATE + 365
|
||||
SELECT 'SEAFARER_REGISTRATION', '${registrationId}', key, CURRENT_DATE, CURRENT_DATE + 365
|
||||
FROM unnest(ARRAY[${documentKeys.map((d) => `'${d}'`).join(',')}]) AS key
|
||||
RETURNING id
|
||||
)
|
||||
@@ -596,12 +502,7 @@ function fillForSubmission(applicationId: string): void {
|
||||
}
|
||||
|
||||
/** Fills what submission requires, then submits as the applicant. */
|
||||
async function submit(
|
||||
applicationId: string,
|
||||
applicant: Applicant,
|
||||
): Promise<void> {
|
||||
fillForSubmission(applicationId);
|
||||
// As the applicant: `submit` is ownership-guarded, so the officer's token —
|
||||
// which every other step here uses — is refused with `not_application_owner`.
|
||||
await runWorkflow(applicationId, [{ path: 'submit' }], applicant);
|
||||
async function submit(registrationId: string, applicant: Applicant): Promise<void> {
|
||||
fillForSubmission(registrationId);
|
||||
await runRegistrationWorkflow(registrationId, [{ path: 'submit' }], applicant);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,10 @@ export function newApplicant(label: string): Applicant {
|
||||
export async function signUp(page: Page, applicant: Applicant): Promise<number> {
|
||||
await page.goto('/signup');
|
||||
|
||||
await page.getByLabel('Name (English)').fill(applicant.name);
|
||||
// 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);
|
||||
await page.getByLabel('Email address').fill(applicant.email);
|
||||
await page.getByLabel('Username').fill(applicant.username);
|
||||
await page.getByLabel('Phone number').fill(applicant.phoneNumber);
|
||||
|
||||
@@ -103,6 +103,13 @@ export function deleteApplicant(email: string): void {
|
||||
if (!userId) return;
|
||||
|
||||
sql(`
|
||||
DELETE FROM attachment_files WHERE attachment_id IN (
|
||||
SELECT id FROM attachments WHERE owner_type = 'SEAFARER_REGISTRATION'
|
||||
AND owner_id IN (SELECT id FROM seafarer_registrations WHERE applicant_user_id = '${userId}')
|
||||
);
|
||||
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 licenses WHERE holder_user_id = '${userId}';
|
||||
DELETE FROM license_applications WHERE applicant_user_id = '${userId}';
|
||||
DELETE FROM profile_operator_types
|
||||
|
||||
@@ -39,7 +39,9 @@ export async function openInQueue(
|
||||
page: Page,
|
||||
applicationNumber: string,
|
||||
): Promise<void> {
|
||||
await page.goto(`${E2E.backofficeUrl}/licence-review/type/SEAFARER_REGISTRATION`);
|
||||
await page.goto(`${E2E.backofficeUrl}/seafarer-registrations`);
|
||||
// Oldest first and paged, so the newest registration is rarely on page one.
|
||||
await page.getByPlaceholder(/search/i).fill(applicationNumber);
|
||||
const row = page.getByRole('row', { name: new RegExp(applicationNumber, 'i') });
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
@@ -155,10 +155,53 @@ export async function runWorkflow(
|
||||
return codes;
|
||||
}
|
||||
|
||||
/** Claim then final-approve — the whole officer path for a registration. */
|
||||
export async function approveRegistration(applicationId: string): Promise<void> {
|
||||
await runWorkflow(applicationId, [
|
||||
/**
|
||||
* The standalone seafarer-registration endpoints — its own controller pair,
|
||||
* not the licence ones above. `submit` is the applicant's; everything else
|
||||
* is the officer's.
|
||||
*/
|
||||
export async function runRegistrationWorkflow(
|
||||
registrationId: string,
|
||||
steps: WorkflowStep[],
|
||||
applicant?: { email: string; password: string },
|
||||
): Promise<number[]> {
|
||||
const officer = await officerContext();
|
||||
const needsApplicant = steps.some((step) => step.path === 'submit');
|
||||
if (needsApplicant && !applicant) {
|
||||
throw new Error('`submit` 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 isApplicantStep = step.path === 'submit';
|
||||
const base = isApplicantStep
|
||||
? 'seafarer-registrations'
|
||||
: 'seafarer-registration-review';
|
||||
const api = isApplicantStep && owner ? owner : officer;
|
||||
const response = await api.post(`${base}/${registrationId}/${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;
|
||||
}
|
||||
|
||||
/** Claim then approve — the whole officer path for a registration. */
|
||||
export async function approveRegistration(registrationId: string): Promise<void> {
|
||||
await runRegistrationWorkflow(registrationId, [
|
||||
{ path: 'claim' },
|
||||
{ path: 'final-approve', data: { remark: 'E2E approval' } },
|
||||
{ path: 'approve', data: { remark: 'E2E approval' } },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,12 @@ import { OperationsFormContent } from '../../profile/components/OperationsFormCo
|
||||
* "I own a vessel" or "I am a seafarer" came here to register, so they are
|
||||
* taken straight to that form instead of a dashboard that only links to it.
|
||||
*
|
||||
* Seafarer used to detour via `/profile` because the wizard refused to open
|
||||
* without a complete one. It no longer does — the Identity Details step
|
||||
* collects those answers itself (`RequireSeafarerProfile`) — so the detour was
|
||||
* only an extra screen between a new signup and the thing they came for.
|
||||
* Seafarer wins when both are ticked; the other form is one nav click away.
|
||||
* Seafarer goes to its own registration page, whose Identity Details step
|
||||
* collects the profile answers itself — no detour via `/profile`. Seafarer
|
||||
* wins when both are ticked; the other form is one nav click away.
|
||||
*/
|
||||
const NEXT_STEP: Record<string, string> = {
|
||||
SEAFARER_REGISTRATION: '/licensing/SEAFARER_REGISTRATION/apply',
|
||||
SEAFARER_REGISTRATION: '/seafarer-registration',
|
||||
VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { Navigate, useParams } from "react-router-dom";
|
||||
import { useCurrentProfile, type ProfileRequirement } from "@ema-platform/auth";
|
||||
import { useGetMyApplicationsQuery } from "@ema-platform/api";
|
||||
import type { ProfileRequirement } from "@ema-platform/auth";
|
||||
|
||||
/**
|
||||
* The identity the seafarer wizard needs before it can produce a registration.
|
||||
* The identity a seafarer registration is built from.
|
||||
*
|
||||
* No longer a gate on opening the wizard: the Identity Details step collects
|
||||
* these itself, so an applicant with an empty profile starts in registration
|
||||
* rather than being sent to `/profile` to prepare for it. Kept because
|
||||
* `ProfilePage` still reads it to show what a seafarer registration will need.
|
||||
* Not a gate: the registration form (`/seafarer-registration`) collects these
|
||||
* itself and prefills from the profile where it can. `ProfilePage` reads it
|
||||
* to show what a registration will need.
|
||||
*/
|
||||
export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
|
||||
fields: [
|
||||
@@ -29,78 +25,3 @@ export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
|
||||
reason:
|
||||
"Seafarer registration is built from your profile — these details fill it in for you.",
|
||||
};
|
||||
|
||||
const REGISTRATION_TYPE_KEY = "SEAFARER_REGISTRATION";
|
||||
|
||||
/**
|
||||
* Opens the existing registration summary when the applicant already holds a
|
||||
* seafarer number, avoiding an attempt to create a duplicate registration.
|
||||
*
|
||||
* It deliberately does *not* gate on profile completeness any more. Selecting
|
||||
* Seafarer Registration now opens the wizard, and the Identity Details step
|
||||
* collects name, gender, DOB, marital status, nationality and national ID
|
||||
* itself — an empty profile is a thing the wizard fills, not a reason to be
|
||||
* sent away from it. Those answers reach the profile when a reviewer approves
|
||||
* the registration (`CompletionEffectService.registerSeafarer`).
|
||||
*
|
||||
* Wraps `/seafarer-registration` directly and `/licensing/:typeCode/apply`
|
||||
* when `typeCode` is the seafarer type — the latter is the shared wizard route
|
||||
* every licence type renders through, so without it a deep link skips this.
|
||||
*/
|
||||
export function RequireSeafarerProfile({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { typeCode } = useParams();
|
||||
const { isLoading, error, profile } = useCurrentProfile();
|
||||
|
||||
// Shared wizard route — only the seafarer type is checked here.
|
||||
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
|
||||
const registered = Boolean(profile?.seafarerNumber);
|
||||
const { data: applications, isLoading: loadingApplications } =
|
||||
useGetMyApplicationsQuery(undefined, {
|
||||
skip: !gated || !registered,
|
||||
});
|
||||
|
||||
if (!gated) return <>{children}</>;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// The number is permanent and the server refuses a second registration.
|
||||
// Keep the registration tab useful by opening the completed application in
|
||||
// its read-only summary instead.
|
||||
if (registered) {
|
||||
if (loadingApplications) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
const registration = applications?.items.find(
|
||||
(application) => application.licenseType?.key === REGISTRATION_TYPE_KEY,
|
||||
);
|
||||
if (registration) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/licensing/${REGISTRATION_TYPE_KEY}/applications/${registration.id}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A failed lookup must not lock anyone out — an unreadable profile says
|
||||
// nothing about whether this applicant is already registered, and the
|
||||
// server refuses a duplicate registration regardless.
|
||||
if (error) return <>{children}</>;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||
uploadDocument,
|
||||
type Attachment,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** The document slots a registration asks for. */
|
||||
export function documentSlots(passportDeclared: boolean) {
|
||||
return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
|
||||
...d,
|
||||
isRequired: d.required === 'passport' ? passportDeclared : d.required,
|
||||
})).filter((d) => d.required !== 'passport' || passportDeclared);
|
||||
}
|
||||
|
||||
export function RegistrationDocuments({
|
||||
registrationId,
|
||||
passportDeclared,
|
||||
attachments,
|
||||
readOnly,
|
||||
onUploaded,
|
||||
}: {
|
||||
registrationId: string;
|
||||
passportDeclared: boolean;
|
||||
attachments: Attachment[];
|
||||
readOnly?: boolean;
|
||||
onUploaded: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resetRefs = useRef<Record<string, () => void>>({});
|
||||
|
||||
async function handle(documentKey: string, file: File | null) {
|
||||
if (!file) return;
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
setError(`File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`);
|
||||
resetRefs.current[documentKey]?.();
|
||||
return;
|
||||
}
|
||||
setBusy(documentKey);
|
||||
setError(null);
|
||||
const result = await uploadDocument({
|
||||
ownerType: 'SEAFARER_REGISTRATION',
|
||||
ownerId: registrationId,
|
||||
documentKey,
|
||||
file,
|
||||
});
|
||||
setBusy(null);
|
||||
resetRefs.current[documentKey]?.();
|
||||
if (result.ok) onUploaded();
|
||||
else setError(result.error);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{documentSlots(passportDeclared).map((slot) => {
|
||||
const existing = attachments.find((a) => a.documentKey === slot.key);
|
||||
const uploaded = Boolean(existing?.files?.length);
|
||||
return (
|
||||
<Card
|
||||
key={slot.key}
|
||||
withBorder
|
||||
padding="md"
|
||||
style={{
|
||||
borderColor: uploaded ? 'var(--mantine-color-teal-4)' : undefined,
|
||||
borderStyle: uploaded ? 'solid' : 'dashed',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{slot.name}
|
||||
</Text>
|
||||
{!slot.isRequired && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
optional
|
||||
</Badge>
|
||||
)}
|
||||
{uploaded && (
|
||||
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
uploaded
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{slot.description && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{slot.description}
|
||||
</Text>
|
||||
)}
|
||||
{existing?.files?.[0] && (
|
||||
<Text size="xs" c="dimmed" truncate mt={2}>
|
||||
{existing.files[0].originalName} · {(existing.files[0].sizeBytes / 1024).toFixed(0)} KB
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{existing?.files?.[0]?.url && (
|
||||
<Button size="xs" variant="subtle" component="a" href={existing.files[0].url} target="_blank">
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<FileButton
|
||||
resetRef={(r) => {
|
||||
if (r) resetRefs.current[slot.key] = r;
|
||||
}}
|
||||
onChange={(file) => handle(slot.key, file)}
|
||||
accept={slot.accept}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="xs"
|
||||
variant={uploaded ? 'light' : 'filled'}
|
||||
leftSection={busy === slot.key ? <Loader size={12} /> : <IconFileUpload size={14} />}
|
||||
disabled={busy === slot.key}
|
||||
>
|
||||
{uploaded ? 'Replace' : 'Upload'}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Divider, Stack, Table, Text } from '@mantine/core';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||
SEAFARER_REGISTRATION_SECTIONS,
|
||||
displaySeafarerAnswer,
|
||||
type Attachment,
|
||||
type SaveSeafarerRegistration,
|
||||
} from '@ema-platform/api';
|
||||
import { documentSlots } from './RegistrationDocuments';
|
||||
|
||||
/** Read-only view of every answer and upload, grouped as the wizard asked them. */
|
||||
export function RegistrationSummary({
|
||||
answers,
|
||||
attachments,
|
||||
}: {
|
||||
answers: SaveSeafarerRegistration;
|
||||
attachments?: Attachment[];
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
|
||||
<div key={section.key}>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
{section.title}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
{section.fields
|
||||
.filter((f) => f !== 'passportExpiry' || answers.passportNumber)
|
||||
.map((field) => (
|
||||
<Table.Tr key={field}>
|
||||
<Table.Td w="45%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{SEAFARER_REGISTRATION_FIELD_LABELS[field]}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{displaySeafarerAnswer(field, answers[field])}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
{attachments && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Documents
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
{documentSlots(Boolean(answers.passportNumber)).map((slot) => {
|
||||
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
|
||||
return (
|
||||
<Table.Tr key={slot.key}>
|
||||
<Table.Td w="45%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{slot.name}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{file ? (
|
||||
file.url ? (
|
||||
<a href={file.url} target="_blank" rel="noreferrer">
|
||||
{file.originalName}
|
||||
</a>
|
||||
) : (
|
||||
<Text size="sm">{file.originalName}</Text>
|
||||
)
|
||||
) : (
|
||||
<Text size="sm" c={slot.isRequired ? 'red' : 'dimmed'}>
|
||||
{slot.isRequired ? 'Missing' : '—'}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Checkbox, Grid, Input, NumberInput, Select, TextInput } from '@mantine/core';
|
||||
import type { SaveSeafarerRegistration } from '@ema-platform/api';
|
||||
import { AmharicDatePicker, CountrySelect, getCountryCode, getCountryName } from '@ema-platform/ui';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
|
||||
export type AnswerKey = keyof SaveSeafarerRegistration;
|
||||
|
||||
/** What every step receives: the answers, a setter, the errors, and whether it is locked. */
|
||||
export interface StepProps {
|
||||
form: SaveSeafarerRegistration;
|
||||
set: (key: AnswerKey, value: unknown) => void;
|
||||
errors: Partial<Record<AnswerKey, string>>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FieldProps extends StepProps {
|
||||
name: AnswerKey;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
maxLength?: number;
|
||||
span?: number;
|
||||
}
|
||||
|
||||
const common = (p: FieldProps) => ({
|
||||
label: p.label,
|
||||
description: p.description,
|
||||
withAsterisk: p.required,
|
||||
error: p.errors[p.name],
|
||||
disabled: p.disabled,
|
||||
});
|
||||
|
||||
const Col = ({ span = 6, children }: { span?: number; children: React.ReactNode }) => (
|
||||
<Grid.Col span={{ base: 12, md: span }}>{children}</Grid.Col>
|
||||
);
|
||||
|
||||
export function TextField(p: FieldProps) {
|
||||
return (
|
||||
<Col span={p.span}>
|
||||
<TextInput
|
||||
{...common(p)}
|
||||
maxLength={p.maxLength}
|
||||
value={(p.form[p.name] as string) ?? ''}
|
||||
onChange={(e) => p.set(p.name, e.currentTarget.value)}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectField(p: FieldProps & { options: { value: string; label: string }[] }) {
|
||||
return (
|
||||
<Col span={p.span}>
|
||||
<Select
|
||||
{...common(p)}
|
||||
data={p.options}
|
||||
value={(p.form[p.name] as string) ?? null}
|
||||
onChange={(v) => p.set(p.name, v)}
|
||||
clearable={!p.required}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export function DateField(p: FieldProps) {
|
||||
return (
|
||||
<Col span={p.span}>
|
||||
<AmharicDatePicker
|
||||
label={p.label}
|
||||
error={p.errors[p.name]}
|
||||
disabled={p.disabled}
|
||||
required={p.required}
|
||||
dateFormat="date"
|
||||
value={(p.form[p.name] as string) ?? ''}
|
||||
onChange={(v) => p.set(p.name, v)}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export function NumberField(p: FieldProps) {
|
||||
return (
|
||||
<Col span={p.span}>
|
||||
<NumberInput
|
||||
{...common(p)}
|
||||
// Not clamped: validation reports an out-of-range figure instead of
|
||||
// Mantine quietly rewriting what the applicant typed.
|
||||
value={(p.form[p.name] as number) ?? ''}
|
||||
onChange={(v) => p.set(p.name, v === '' ? null : Number(v))}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stored as the full country name, like the profile; picked by alpha-2 code. */
|
||||
export function NationalityField(p: FieldProps) {
|
||||
const stored = (p.form[p.name] as string) ?? '';
|
||||
return (
|
||||
<Col span={p.span}>
|
||||
<CountrySelect
|
||||
{...common(p)}
|
||||
required={p.required}
|
||||
demonym
|
||||
value={getCountryCode(stored) ?? (stored || null)}
|
||||
onChange={(code) => p.set(p.name, code ? getCountryName(code) || code : null)}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationField(p: FieldProps) {
|
||||
return (
|
||||
<Col span={p.span}>
|
||||
<Input.Wrapper
|
||||
label={p.label}
|
||||
description={p.description}
|
||||
withAsterisk={p.required}
|
||||
error={p.errors[p.name]}
|
||||
>
|
||||
<LocationPicker
|
||||
value={(p.form[p.name] as string) ?? undefined}
|
||||
onChange={(id) => p.set(p.name, id)}
|
||||
required={p.required}
|
||||
maxDepth={3}
|
||||
disabled={p.disabled}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckboxField(p: FieldProps) {
|
||||
return (
|
||||
<Col span={12}>
|
||||
<Checkbox
|
||||
label={p.label}
|
||||
error={p.errors[p.name]}
|
||||
disabled={p.disabled}
|
||||
checked={Boolean(p.form[p.name])}
|
||||
onChange={(e) => p.set(p.name, e.currentTarget.checked)}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Divider, Grid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import {
|
||||
BLOOD_TYPE_OPTIONS,
|
||||
DEPARTMENT_OPTIONS,
|
||||
EYE_COLOR_OPTIONS,
|
||||
GENDER_OPTIONS,
|
||||
HAIR_COLOR_OPTIONS,
|
||||
MARITAL_STATUS_OPTIONS,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
DateField,
|
||||
LocationField,
|
||||
NationalityField,
|
||||
NumberField,
|
||||
SelectField,
|
||||
TextField,
|
||||
type StepProps,
|
||||
} from './fields';
|
||||
|
||||
function SectionTitle({ title, description }: { title: string; description?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 — Identity Details.
|
||||
*
|
||||
* Email and phone are account credentials: shown, never edited here. The
|
||||
* rest is prefilled from the profile and editable — what is entered becomes
|
||||
* the registered identity once a reviewer approves.
|
||||
*/
|
||||
export function IdentityDetailsStep(
|
||||
p: StepProps & { account: { email?: string; phoneNumber?: string } },
|
||||
) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Contact Details" />
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput label="Account Email" value={p.account.email ?? ''} disabled />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput label="Account Phone Number" value={p.account.phoneNumber ?? ''} disabled />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Divider />
|
||||
<SectionTitle
|
||||
title="Identity Details"
|
||||
description="Prefilled from your profile where we have it. Check each field and correct anything that is wrong — what you enter here becomes your registered identity."
|
||||
/>
|
||||
<Grid>
|
||||
<TextField {...p} name="firstName" label="First Name" required maxLength={128} />
|
||||
<TextField {...p} name="middleName" label="Middle Name" maxLength={128} />
|
||||
<TextField {...p} name="lastName" label="Last Name" required maxLength={128} />
|
||||
<SelectField {...p} name="gender" label="Gender" required options={GENDER_OPTIONS} />
|
||||
<DateField {...p} name="dateOfBirth" label="Date of Birth" required />
|
||||
<SelectField {...p} name="maritalStatus" label="Marital Status" required options={MARITAL_STATUS_OPTIONS} />
|
||||
<NationalityField {...p} name="nationality" label="Nationality" required />
|
||||
<TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} />
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Step 2 — Identity, Address, Physical Characteristics, Medical Certificate. */
|
||||
export function ApplicantDetailsStep(p: StepProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Identity" />
|
||||
<Grid>
|
||||
<TextField {...p} name="placeOfBirth" label="Place of Birth" required maxLength={128} />
|
||||
<TextField
|
||||
{...p}
|
||||
name="passportNumber"
|
||||
label="Passport Number"
|
||||
maxLength={32}
|
||||
description="Required later for international sea service; optional at registration."
|
||||
/>
|
||||
{p.form.passportNumber && (
|
||||
<DateField {...p} name="passportExpiry" label="Passport Expiry Date" />
|
||||
)}
|
||||
<SelectField
|
||||
{...p}
|
||||
name="department"
|
||||
label="Department"
|
||||
required
|
||||
options={DEPARTMENT_OPTIONS}
|
||||
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Divider />
|
||||
<SectionTitle title="Address" />
|
||||
<Grid>
|
||||
<LocationField
|
||||
{...p}
|
||||
name="locationId"
|
||||
label="Location"
|
||||
required
|
||||
description="City / sub-city selected from the location picker."
|
||||
/>
|
||||
<TextField {...p} name="permanentAddress" label="Permanent Address" maxLength={255} />
|
||||
<TextField
|
||||
{...p}
|
||||
name="currentAddress"
|
||||
label="Current Address"
|
||||
maxLength={255}
|
||||
description="Where you currently live, if different from the permanent address."
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Divider />
|
||||
<SectionTitle
|
||||
title="Physical Characteristics"
|
||||
description="Identifying details printed in your Seaman Book."
|
||||
/>
|
||||
<Grid>
|
||||
<SelectField {...p} name="hairColor" label="Hair Colour" required options={HAIR_COLOR_OPTIONS} />
|
||||
<SelectField {...p} name="eyeColor" label="Eye Colour" required options={EYE_COLOR_OPTIONS} />
|
||||
<NumberField {...p} name="heightCm" label="Height (cm)" required description="In centimetres, e.g. 172.5" />
|
||||
<NumberField {...p} name="weightKg" label="Weight (kg)" required description="In kilograms, e.g. 68.0" />
|
||||
<SelectField
|
||||
{...p}
|
||||
name="bloodType"
|
||||
label="Blood Type"
|
||||
options={BLOOD_TYPE_OPTIONS}
|
||||
description="Optional. Select Unknown if you have not been tested."
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Divider />
|
||||
<SectionTitle
|
||||
title="Medical Certificate"
|
||||
description="Details from your STCW medical fitness certificate. The expiry date is calculated from the issue date."
|
||||
/>
|
||||
<Grid>
|
||||
<TextField {...p} name="medicalCertificateNumber" label="Certificate Number" required maxLength={64} />
|
||||
<TextField {...p} name="medicalIssuerName" label="Issuing Clinic or Practitioner" required maxLength={255} />
|
||||
<DateField
|
||||
{...p}
|
||||
name="medicalIssueDate"
|
||||
label="Issue Date"
|
||||
required
|
||||
description="Cannot be a future date. Validity is calculated from this: two years, or one year if you are under 18."
|
||||
/>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Step 3 — Emergency Contact. */
|
||||
export function EmergencyContactStep(p: StepProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
|
||||
<Grid>
|
||||
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
|
||||
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
|
||||
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil } from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
PHYSICAL_BOUNDS,
|
||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||
SEAFARER_REGISTRATION_STATUS_COLORS,
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
useGetAttachmentsQuery,
|
||||
useGetMySeafarerRegistrationQuery,
|
||||
useSaveSeafarerRegistrationMutation,
|
||||
useStartSeafarerRegistrationMutation,
|
||||
useSubmitSeafarerRegistrationMutation,
|
||||
type SaveSeafarerRegistration,
|
||||
type SeafarerRegistration,
|
||||
type ValidationIssue,
|
||||
} from '@ema-platform/api';
|
||||
import { splitPersonName } from '@ema-platform/ui';
|
||||
import { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { CheckboxField, type AnswerKey } from '../components/fields';
|
||||
import { ApplicantDetailsStep, EmergencyContactStep, IdentityDetailsStep } from '../components/steps';
|
||||
import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments';
|
||||
import { RegistrationSummary } from '../components/RegistrationSummary';
|
||||
|
||||
const STEPS = ['Identity Details', 'Applicant Details', 'Emergency Contact', 'Documents', 'Review'];
|
||||
|
||||
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
|
||||
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
|
||||
[
|
||||
'placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg',
|
||||
'medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate',
|
||||
],
|
||||
[],
|
||||
[],
|
||||
['declarationAccepted'],
|
||||
];
|
||||
|
||||
const ANSWER_KEYS = Object.keys(SEAFARER_REGISTRATION_FIELD_LABELS) as AnswerKey[];
|
||||
|
||||
function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration {
|
||||
return Object.fromEntries(ANSWER_KEYS.map((k) => [k, registration[k]])) as SaveSeafarerRegistration;
|
||||
}
|
||||
|
||||
function blank(value: unknown): boolean {
|
||||
return value === null || value === undefined || value === '' || value === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills blank answers from the profile.
|
||||
*
|
||||
* The API prefills a draft when it is opened, but an applicant who declared
|
||||
* "seafarer" at onboarding lands here before their profile has an address —
|
||||
* so whatever they fill in on /profile afterwards would never reach a draft
|
||||
* already open. Blank-only: an answer the applicant typed or the server saved
|
||||
* is left alone.
|
||||
*/
|
||||
function withProfileDefaults(
|
||||
answers: SaveSeafarerRegistration,
|
||||
profile: CurrentProfile | undefined,
|
||||
accountName: string | undefined,
|
||||
): SaveSeafarerRegistration {
|
||||
if (!profile) return answers;
|
||||
const a = profile.address;
|
||||
const parts = accountName ? splitPersonName(accountName) : null;
|
||||
const defaults: SaveSeafarerRegistration = {
|
||||
firstName: profile.firstName || parts?.firstName || null,
|
||||
middleName: profile.middleName || parts?.middleName || null,
|
||||
lastName: profile.lastName || parts?.lastName || null,
|
||||
gender: (profile.gender as SaveSeafarerRegistration['gender']) || null,
|
||||
dateOfBirth: profile.dob ? profile.dob.slice(0, 10) : null,
|
||||
maritalStatus: (profile.maritalStatus as SaveSeafarerRegistration['maritalStatus']) || null,
|
||||
placeOfBirth: profile.pob || null,
|
||||
nationality: a?.nationality || null,
|
||||
nationalIdNumber: a?.idType === 'NID' ? a.idNumber || null : null,
|
||||
passportNumber: a?.passportNumber || null,
|
||||
passportExpiry: a?.passportExpiry || null,
|
||||
permanentAddress: a?.streetAddress || null,
|
||||
currentAddress: a?.currentAddress || null,
|
||||
emergencyContactName: a?.emergencyContactName || null,
|
||||
emergencyContactPhone: a?.emergencyContactPhone || null,
|
||||
emergencyContactRelationship: a?.emergencyContactRelation || null,
|
||||
department: profile.seafarerDepartment || null,
|
||||
};
|
||||
const next = { ...answers };
|
||||
for (const [key, value] of Object.entries(defaults) as [AnswerKey, unknown][]) {
|
||||
if (blank(next[key]) && !blank(value)) (next as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seafarer registration — a fixed five-step form, not a configured wizard.
|
||||
*
|
||||
* A draft is opened on first visit so uploads have an owner and nothing is
|
||||
* lost if the browser closes mid-way. Each "Continue" validates the step and
|
||||
* saves it; Submit saves everything and asks the API, which names anything
|
||||
* still missing. A submitted registration opens to a read-only summary.
|
||||
*/
|
||||
export function SeafarerRegistrationPage() {
|
||||
const accountUser = useAppSelector((state) => state.auth.user);
|
||||
const { profile } = useCurrentProfile();
|
||||
const { data, isLoading } = useGetMySeafarerRegistrationQuery();
|
||||
const registration = data?.registration ?? null;
|
||||
|
||||
const [start] = useStartSeafarerRegistrationMutation();
|
||||
const [save] = useSaveSeafarerRegistrationMutation();
|
||||
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
const started = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || registration || started.current) return;
|
||||
started.current = true;
|
||||
start()
|
||||
.unwrap()
|
||||
.catch((err) => setStartError(extractErrorMessage(err)));
|
||||
}, [isLoading, registration, start]);
|
||||
|
||||
const { data: attachments = [], refetch: refetchAttachments } = useGetAttachmentsQuery(
|
||||
{ ownerType: 'SEAFARER_REGISTRATION', ownerId: registration?.id ?? '' },
|
||||
{ skip: !registration },
|
||||
);
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
const [viewingSummary, setViewingSummary] = useState(true);
|
||||
const [form, setForm] = useState<SaveSeafarerRegistration>({});
|
||||
const [errors, setErrors] = useState<Partial<Record<AnswerKey, string>>>({});
|
||||
const [issues, setIssues] = useState<ValidationIssue[]>([]);
|
||||
|
||||
const accountName = accountUser?.name?.en ?? profile?.user?.name?.en;
|
||||
const isDraft = registration?.status === 'DRAFT';
|
||||
|
||||
// Seed local edits from the server copy when the registration (or its
|
||||
// round) changes — not on every refetch, which would wipe typing in progress.
|
||||
useEffect(() => {
|
||||
if (!registration) return;
|
||||
const answers = answersOf(registration);
|
||||
setForm(isDraft ? withProfileDefaults(answers, profile, accountName) : answers);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [registration?.id, registration?.status]);
|
||||
|
||||
// The profile can arrive after the draft did; fill what is still blank.
|
||||
useEffect(() => {
|
||||
if (!isDraft || !profile) return;
|
||||
setForm((prev) => withProfileDefaults(prev, profile, accountName));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profile?.id, profile?.address?.id, isDraft]);
|
||||
|
||||
if (startError) {
|
||||
return (
|
||||
<Container size="md" py="xl">
|
||||
<Alert color={profile?.seafarerNumber ? 'teal' : 'red'} icon={<IconInfoCircle size={16} />} title="Seafarer Registration">
|
||||
{profile?.seafarerNumber
|
||||
? `You are already registered as a seafarer (${profile.seafarerNumber}).`
|
||||
: startError}
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !registration) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const isAdjusting = registration.status === 'RESUBMIT_REQUIRED';
|
||||
const editableWhileSubmitted = registration.status === 'SUBMITTED' && !registration.assignedOfficerId;
|
||||
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(registration.status) && !editableWhileSubmitted;
|
||||
const showSummary = registration.status !== 'DRAFT' && viewingSummary;
|
||||
|
||||
function set(key: AnswerKey, value: unknown) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setErrors((prev) => {
|
||||
if (!prev[key]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function validateStep(index: number): boolean {
|
||||
const found: Partial<Record<AnswerKey, string>> = {};
|
||||
for (const key of REQUIRED_BY_STEP[index] ?? []) {
|
||||
if (blank(form[key])) found[key] = `${SEAFARER_REGISTRATION_FIELD_LABELS[key]} is required.`;
|
||||
}
|
||||
if (index === 1) {
|
||||
const { heightCm, weightKg } = PHYSICAL_BOUNDS;
|
||||
if (typeof form.heightCm === 'number' && (form.heightCm < heightCm.min || form.heightCm > heightCm.max)) {
|
||||
found.heightCm = `Enter a height between ${heightCm.min} and ${heightCm.max} cm.`;
|
||||
}
|
||||
if (typeof form.weightKg === 'number' && (form.weightKg < weightKg.min || form.weightKg > weightKg.max)) {
|
||||
found.weightKg = `Enter a weight between ${weightKg.min} and ${weightKg.max} kg.`;
|
||||
}
|
||||
}
|
||||
setErrors(found);
|
||||
const count = Object.keys(found).length;
|
||||
if (count) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Incomplete',
|
||||
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (index === 3) {
|
||||
const supplied = new Set(attachments.filter((a) => a.files?.length).map((a) => a.documentKey));
|
||||
const missing = documentSlots(Boolean(form.passportNumber))
|
||||
.filter((d) => d.isRequired && !supplied.has(d.key))
|
||||
.map((d) => d.name);
|
||||
if (missing.length) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Documents missing',
|
||||
message: `Upload: ${missing.join(', ')}.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveAnswers(): Promise<boolean> {
|
||||
if (readOnly || !registration) return true;
|
||||
try {
|
||||
await save({ id: registration.id, body: form }).unwrap();
|
||||
return true;
|
||||
} catch (err) {
|
||||
notifications.show({ color: 'red', title: 'Could not save', message: extractErrorMessage(err) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function goToStep(target: number) {
|
||||
if (target <= active) {
|
||||
setActive(target);
|
||||
return;
|
||||
}
|
||||
// Going forward validates every step passed over, so a jump cannot skip a
|
||||
// required field; the walk stops on the first step that fails.
|
||||
for (let step = active; step < target; step++) {
|
||||
if (!readOnly && !validateStep(step)) {
|
||||
setActive(step);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(await saveAnswers())) return;
|
||||
setErrors({});
|
||||
setActive(target);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!registration) return;
|
||||
setIssues([]);
|
||||
if (!readOnly && !validateStep(4)) return;
|
||||
if (!(await saveAnswers())) return;
|
||||
try {
|
||||
await submit(registration.id).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: isAdjusting ? 'Resubmitted' : 'Registration submitted',
|
||||
message: isAdjusting
|
||||
? 'Your corrections were sent back to the reviewing officer.'
|
||||
: 'You will be notified as it progresses.',
|
||||
});
|
||||
setViewingSummary(true);
|
||||
} catch (err) {
|
||||
const found = extractValidationIssues(err);
|
||||
setIssues(found);
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Registration incomplete',
|
||||
message: found.length ? `${found.length} item(s) still need attention.` : extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stepProps = { form, set, errors, disabled: readOnly };
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Group justify="space-between" mb="xs" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registration</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{registration.registrationNumber}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
|
||||
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
|
||||
</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
{showSummary && !readOnly && (
|
||||
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
|
||||
Edit details
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{registration.status === 'APPROVED' && (
|
||||
<Alert color="teal" icon={<IconCheck size={16} />} title="Registered" mb="md">
|
||||
You are a registered seafarer. Your seafarer number is <b>{registration.seafarerNumber}</b>.
|
||||
Your Seaman Book and Basic Training Certificate applications have been opened for you.
|
||||
</Alert>
|
||||
)}
|
||||
{registration.status === 'REJECTED' && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Registration rejected" mb="md">
|
||||
{registration.rejectionReason}
|
||||
</Alert>
|
||||
)}
|
||||
{isAdjusting && (
|
||||
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Corrections requested" mb="md">
|
||||
{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.
|
||||
</Alert>
|
||||
)}
|
||||
{issues.length > 0 && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Still missing" mb="md">
|
||||
<Stack gap={2}>
|
||||
{issues.map((issue, i) => (
|
||||
<Text size="sm" key={i}>
|
||||
• {issue.message}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showSummary && (
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<RegistrationSummary answers={answersOf(registration)} attachments={attachments} />
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!showSummary && (
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
|
||||
{STEPS.map((label) => (
|
||||
<Stepper.Step key={label} label={label} />
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{active === 0 && (
|
||||
<IdentityDetailsStep
|
||||
{...stepProps}
|
||||
account={{ email: accountUser?.email, phoneNumber: accountUser?.phoneNumber }}
|
||||
/>
|
||||
)}
|
||||
{active === 1 && <ApplicantDetailsStep {...stepProps} />}
|
||||
{active === 2 && <EmergencyContactStep {...stepProps} />}
|
||||
{active === 3 && (
|
||||
<RegistrationDocuments
|
||||
registrationId={registration.id}
|
||||
passportDeclared={Boolean(form.passportNumber)}
|
||||
attachments={attachments}
|
||||
readOnly={readOnly}
|
||||
onUploaded={refetchAttachments}
|
||||
/>
|
||||
)}
|
||||
{active === 4 && (
|
||||
<Stack>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">
|
||||
Declaration
|
||||
</Text>
|
||||
<Grid>
|
||||
<CheckboxField
|
||||
{...stepProps}
|
||||
name="declarationAccepted"
|
||||
label="I declare that the information provided is complete and accurate."
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
<Divider my="md" />
|
||||
<Title order={5}>Review</Title>
|
||||
<RegistrationSummary answers={form} attachments={attachments} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" onClick={() => setActive((s) => Math.max(0, s - 1))} disabled={active === 0}>
|
||||
Back
|
||||
</Button>
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button onClick={() => goToStep(active + 1)}>Continue</Button>
|
||||
) : (
|
||||
<Button color="teal" loading={submitting} disabled={readOnly} onClick={handleSubmit}>
|
||||
{isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeafarerRegistrationPage;
|
||||
@@ -23,11 +23,11 @@ const L = LICENSE_PERMISSIONS;
|
||||
// Portal feature pages
|
||||
import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
|
||||
import { RequireOperations } from "./features/onboarding/components/RequireOperations";
|
||||
import { RequireSeafarerProfile } from "./features/profile/components/RequireSeafarerProfile";
|
||||
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
||||
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
|
||||
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
|
||||
// Phase 1 pages
|
||||
@@ -140,14 +140,21 @@ export const router = createBrowserRouter([
|
||||
path: "/licensing/SEAMAN_BOOK/applications/:applicationId",
|
||||
element: <Navigate to="/seaman-book" replace />,
|
||||
},
|
||||
// Seafarer registration is not a licence: it has its own page and API.
|
||||
{
|
||||
path: "/licensing/SEAFARER_REGISTRATION/apply",
|
||||
element: <Navigate to="/seafarer-registration" replace />,
|
||||
},
|
||||
{
|
||||
path: "/licensing/SEAFARER_REGISTRATION/applications/:applicationId",
|
||||
element: <Navigate to="/seafarer-registration" replace />,
|
||||
},
|
||||
{
|
||||
path: "/licensing/:typeCode/apply",
|
||||
element: (
|
||||
<RequireSeafarerProfile>
|
||||
<RequirePermission anyOf={[L.CREATE_APPLICATION]}>
|
||||
<LicenseApplicationPage />
|
||||
</RequirePermission>
|
||||
</RequireSeafarerProfile>
|
||||
<RequirePermission anyOf={[L.CREATE_APPLICATION]}>
|
||||
<LicenseApplicationPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -171,17 +178,15 @@ export const router = createBrowserRouter([
|
||||
|
||||
// Seafarer
|
||||
//
|
||||
// Registration runs through the shared application wizard like every
|
||||
// other service. It used to have a page of its own that wrote the
|
||||
// profile directly and created no application at all — which meant a
|
||||
// "submitted" registration was never reviewed, never approved and never
|
||||
// numbered, because there was nothing for an officer to open. The
|
||||
// wizard, the review queue and the approval side effects all already
|
||||
// existed; only the route was pointed away from them.
|
||||
// Registration is its own five-step form over its own endpoints —
|
||||
// not a configured licence type. A draft opens on first visit; once
|
||||
// submitted the page shows the registration's status and answers.
|
||||
{
|
||||
path: "/seafarer-registration",
|
||||
element: (
|
||||
<Navigate to="/licensing/SEAFARER_REGISTRATION/apply" replace />
|
||||
<RequirePermission anyOf={[P.APPLY_SEAFARER_REGISTRATION]}>
|
||||
<SeafarerRegistrationPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user