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:
Nati
2026-08-20 07:14:57 +00:00
parent 688b111b6a
commit 818964a694
24 changed files with 2147 additions and 445 deletions

View File

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

View File

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

View File

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

View File

@@ -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 />) },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
),
},
{

View File

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

View File

@@ -27,7 +27,8 @@ export async function uploadDocument(params: {
| 'INSPECTION'
| 'LICENSE'
| 'SEA_SERVICE_RECORD'
| 'MEDICAL_CERTIFICATE';
| 'MEDICAL_CERTIFICATE'
| 'SEAFARER_REGISTRATION';
ownerId: string;
documentKey: string;
file: File;

View File

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

View File

@@ -0,0 +1,124 @@
import { baseApi } from '../../base-api';
import type { Attachment } from '../licensing/licensing.types';
import type {
SaveSeafarerRegistration,
SeafarerRegistration,
SeafarerRegistrationStatus,
} from './seafarer-registration.types';
const TAG = 'SeafarerRegistration' as const;
const LIST = { type: TAG, id: 'LIST' } as const;
const item = (id: string) => ({ type: TAG, id }) as const;
export interface SeafarerRegistrationListFilter {
status?: SeafarerRegistrationStatus;
search?: string;
take?: number;
skip?: number;
}
/**
* Seafarer registration — its own endpoints, not a licence application.
* Uploads still go through `/attachments` with ownerType SEAFARER_REGISTRATION.
*/
export const seafarerRegistrationApi = baseApi
.enhanceEndpoints({ addTagTypes: [TAG] })
.injectEndpoints({
endpoints: (builder) => ({
// ------------------------------------------------------------ applicant
getMySeafarerRegistration: builder.query<{ registration: SeafarerRegistration | null }, void>({
query: () => ({ url: '/seafarer-registrations/mine' }),
providesTags: (r) => [LIST, ...(r?.registration ? [item(r.registration.id)] : [])],
}),
startSeafarerRegistration: builder.mutation<SeafarerRegistration, void>({
query: () => ({ url: '/seafarer-registrations', method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
saveSeafarerRegistration: builder.mutation<
SeafarerRegistration,
{ id: string; body: SaveSeafarerRegistration }
>({
query: ({ id, body }) => ({ url: `/seafarer-registrations/${id}`, method: 'PUT', body }),
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
}),
submitSeafarerRegistration: builder.mutation<SeafarerRegistration, string>({
query: (id) => ({ url: `/seafarer-registrations/${id}/submit`, method: 'POST' }),
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
// --------------------------------------------------------------- review
listSeafarerRegistrations: builder.query<
{ total: number; items: SeafarerRegistration[] },
SeafarerRegistrationListFilter
>({
query: (params) => ({ url: '/seafarer-registration-review', params }),
providesTags: () => [LIST],
}),
getSeafarerRegistrationReview: builder.query<
{ registration: SeafarerRegistration; attachments: Attachment[] },
string
>({
query: (id) => ({ url: `/seafarer-registration-review/${id}` }),
providesTags: (_r, _e, id) => [item(id)],
}),
claimSeafarerRegistration: builder.mutation<SeafarerRegistration, string>({
query: (id) => ({ url: `/seafarer-registration-review/${id}/claim`, method: 'POST' }),
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
approveSeafarerRegistration: builder.mutation<
SeafarerRegistration,
{ id: string; remark?: string }
>({
query: ({ id, ...body }) => ({
url: `/seafarer-registration-review/${id}/approve`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
}),
rejectSeafarerRegistration: builder.mutation<
SeafarerRegistration,
{ id: string; reason: string }
>({
query: ({ id, ...body }) => ({
url: `/seafarer-registration-review/${id}/reject`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
}),
requestSeafarerRegistrationChanges: builder.mutation<
SeafarerRegistration,
{ id: string; remark: string }
>({
query: ({ id, ...body }) => ({
url: `/seafarer-registration-review/${id}/request-changes`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
}),
}),
overrideExisting: false,
});
export const {
useGetMySeafarerRegistrationQuery,
useStartSeafarerRegistrationMutation,
useSaveSeafarerRegistrationMutation,
useSubmitSeafarerRegistrationMutation,
useListSeafarerRegistrationsQuery,
useGetSeafarerRegistrationReviewQuery,
useClaimSeafarerRegistrationMutation,
useApproveSeafarerRegistrationMutation,
useRejectSeafarerRegistrationMutation,
useRequestSeafarerRegistrationChangesMutation,
} = seafarerRegistrationApi;

View File

@@ -0,0 +1,211 @@
import type {
SeafarerRegistrationAnswers,
SeafarerRegistrationStatus,
} from './seafarer-registration.types';
/**
* Option lists and labels shared by the portal wizard and the backoffice
* review screen. Values are the server's enum members.
*/
export const GENDER_OPTIONS = [
{ value: 'MALE', label: 'Male' },
{ value: 'FEMALE', label: 'Female' },
];
export const MARITAL_STATUS_OPTIONS = [
{ value: 'SINGLE', label: 'Single' },
{ value: 'MARRIED', label: 'Married' },
{ value: 'DIVORCED', label: 'Divorced' },
{ value: 'WIDOWED', label: 'Widowed' },
];
export const DEPARTMENT_OPTIONS = [
{ value: 'DECK', label: 'Deck' },
{ value: 'ENGINE', label: 'Engine' },
{ value: 'CATERING', label: 'Catering' },
];
export const HAIR_COLOR_OPTIONS = [
{ value: 'BLACK', label: 'Black' },
{ value: 'BROWN', label: 'Brown' },
{ value: 'BLONDE', label: 'Blonde' },
{ value: 'RED', label: 'Red' },
{ value: 'GREY', label: 'Grey' },
{ value: 'WHITE', label: 'White' },
{ value: 'BALD', label: 'Bald' },
{ value: 'OTHER', label: 'Other' },
];
export const EYE_COLOR_OPTIONS = [
{ value: 'BROWN', label: 'Brown' },
{ value: 'BLACK', label: 'Black' },
{ value: 'BLUE', label: 'Blue' },
{ value: 'GREEN', label: 'Green' },
{ value: 'HAZEL', label: 'Hazel' },
{ value: 'GREY', label: 'Grey' },
{ value: 'OTHER', label: 'Other' },
];
export const BLOOD_TYPE_OPTIONS = [
{ value: 'A_POSITIVE', label: 'A+' },
{ value: 'A_NEGATIVE', label: 'A' },
{ value: 'B_POSITIVE', label: 'B+' },
{ value: 'B_NEGATIVE', label: 'B' },
{ value: 'AB_POSITIVE', label: 'AB+' },
{ value: 'AB_NEGATIVE', label: 'AB' },
{ value: 'O_POSITIVE', label: 'O+' },
{ value: 'O_NEGATIVE', label: 'O' },
{ value: 'UNKNOWN', label: 'Unknown' },
];
/** Same plausibility bounds the API enforces (PHYSICAL_BOUNDS). */
export const PHYSICAL_BOUNDS = {
heightCm: { min: 100, max: 250 },
weightKg: { min: 30, max: 250 },
} as const;
/** Upload slots, keyed as the API's submission check expects them. */
export const SEAFARER_REGISTRATION_DOCUMENTS: {
key: string;
name: string;
description?: string;
/** `'passport'`: required only once a passport number is declared. */
required: boolean | 'passport';
accept?: string;
}[] = [
{
key: 'photo',
name: 'Passport-size Photograph',
description: 'Recent colour photograph with a plain background.',
required: true,
accept: 'image/jpeg,image/png',
},
{ key: 'nationalId', name: 'National ID (Fayda) or Kebele ID', required: true },
{ key: 'passport', name: 'Passport Copy', required: 'passport' },
{ key: 'graduation', name: 'Educational Certificate', required: false },
{
key: 'medical_certificate',
name: 'Medical Certificate',
description:
'Your STCW medical fitness certificate. Must match the certificate details entered above.',
required: true,
},
{
key: 'basic_training_evidence',
name: 'Basic Training Evidence',
description:
'One file containing evidence of all five basic training competencies: Personal Survival Techniques (PST), Fire Prevention and Fire Fighting (FPFF), Elementary First Aid (EFA), Personal Safety and Social Responsibility (PSSR), and Security Awareness.',
required: true,
},
];
export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
RESUBMIT_REQUIRED: 'Corrections Requested',
APPROVED: 'Approved',
REJECTED: 'Rejected',
};
export const SEAFARER_REGISTRATION_STATUS_COLORS: Record<SeafarerRegistrationStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'indigo',
RESUBMIT_REQUIRED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',
};
/** Human label for each answer — the review table and the summary both use it. */
export const SEAFARER_REGISTRATION_FIELD_LABELS: Record<keyof SeafarerRegistrationAnswers, string> = {
firstName: 'First Name',
middleName: 'Middle Name',
lastName: 'Last Name',
gender: 'Gender',
dateOfBirth: 'Date of Birth',
maritalStatus: 'Marital Status',
nationality: 'Nationality',
nationalIdNumber: 'National ID (Fayda) Number',
placeOfBirth: 'Place of Birth',
passportNumber: 'Passport Number',
passportExpiry: 'Passport Expiry Date',
department: 'Department',
locationId: 'Location',
permanentAddress: 'Permanent Address',
currentAddress: 'Current Address',
emergencyContactName: 'Full Name',
emergencyContactRelationship: 'Relationship',
emergencyContactPhone: 'Phone Number',
hairColor: 'Hair Colour',
eyeColor: 'Eye Colour',
heightCm: 'Height (cm)',
weightKg: 'Weight (kg)',
bloodType: 'Blood Type',
medicalCertificateNumber: 'Certificate Number',
medicalIssuerName: 'Issuing Clinic or Practitioner',
medicalIssueDate: 'Issue Date',
declarationAccepted: 'Declaration',
};
/**
* The answers grouped the way the wizard asks them — the summary on the
* portal and the review screen in the backoffice render the same sections.
*/
export const SEAFARER_REGISTRATION_SECTIONS: {
key: string;
title: string;
fields: (keyof SeafarerRegistrationAnswers)[];
}[] = [
{
key: 'identityDetails',
title: 'Identity Details',
fields: ['firstName', 'middleName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
},
{
key: 'identity',
title: 'Identity',
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department'],
},
{
key: 'address',
title: 'Address',
fields: ['locationId', 'permanentAddress', 'currentAddress'],
},
{
key: 'physicalCharacteristics',
title: 'Physical Characteristics',
fields: ['hairColor', 'eyeColor', 'heightCm', 'weightKg', 'bloodType'],
},
{
key: 'medicalCertificate',
title: 'Medical Certificate',
fields: ['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
},
{
key: 'emergencyContact',
title: 'Emergency Contact',
fields: ['emergencyContactName', 'emergencyContactRelationship', 'emergencyContactPhone'],
},
];
const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value: string; label: string }[]>> = {
gender: GENDER_OPTIONS,
maritalStatus: MARITAL_STATUS_OPTIONS,
department: DEPARTMENT_OPTIONS,
hairColor: HAIR_COLOR_OPTIONS,
eyeColor: EYE_COLOR_OPTIONS,
bloodType: BLOOD_TYPE_OPTIONS,
};
/** Display value for one answer: option label for enums, "—" when blank. */
export function displaySeafarerAnswer(
field: keyof SeafarerRegistrationAnswers,
value: unknown,
): string {
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
const options = OPTION_LABELS[field];
if (options) return options.find((o) => o.value === value)?.label ?? String(value);
return String(value);
}

View File

@@ -0,0 +1,78 @@
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
export type SeafarerRegistrationStatus =
| 'DRAFT'
| 'SUBMITTED'
| 'UNDER_REVIEW'
| 'RESUBMIT_REQUIRED'
| 'APPROVED'
| 'REJECTED';
export type Gender = 'MALE' | 'FEMALE';
export type MaritalStatus = 'SINGLE' | 'MARRIED' | 'DIVORCED' | 'WIDOWED';
export type HairColor = 'BLACK' | 'BROWN' | 'BLONDE' | 'RED' | 'GREY' | 'WHITE' | 'BALD' | 'OTHER';
export type EyeColor = 'BROWN' | 'BLACK' | 'BLUE' | 'GREEN' | 'HAZEL' | 'GREY' | 'OTHER';
export type BloodType =
| 'A_POSITIVE' | 'A_NEGATIVE' | 'B_POSITIVE' | 'B_NEGATIVE'
| 'AB_POSITIVE' | 'AB_NEGATIVE' | 'O_POSITIVE' | 'O_NEGATIVE' | 'UNKNOWN';
/** Every answer the registration collects — one typed row, no form schema. */
export interface SeafarerRegistrationAnswers {
firstName: string | null;
middleName: string | null;
lastName: string | null;
gender: Gender | null;
dateOfBirth: string | null;
maritalStatus: MaritalStatus | null;
/** Full demonym as the profile stores it ("Ethiopian"). */
nationality: string | null;
nationalIdNumber: string | null;
placeOfBirth: string | null;
passportNumber: string | null;
passportExpiry: string | null;
department: SeafarerDepartment | null;
locationId: string | null;
permanentAddress: string | null;
currentAddress: string | null;
emergencyContactName: string | null;
emergencyContactRelationship: string | null;
emergencyContactPhone: string | null;
hairColor: HairColor | null;
eyeColor: EyeColor | null;
heightCm: number | null;
weightKg: number | null;
bloodType: BloodType | null;
medicalCertificateNumber: string | null;
medicalIssuerName: string | null;
medicalIssueDate: string | null;
declarationAccepted: boolean;
}
export interface SeafarerRegistration extends SeafarerRegistrationAnswers {
id: string;
registrationNumber: string;
applicantUserId: string;
profileId: string | null;
status: SeafarerRegistrationStatus;
submittedAt: string | null;
assignedOfficerId: string | null;
claimedAt: string | null;
decidedAt: string | null;
decidedById: string | null;
/** What the officer asked to be fixed, or noted at approval. */
reviewRemark: string | null;
rejectionReason: string | null;
/** Stamped at approval. */
seafarerNumber: string | null;
createdAt: string;
updatedAt: string;
}
/** `null` clears a field; an absent key leaves it alone. */
export type SaveSeafarerRegistration = Partial<SeafarerRegistrationAnswers>;
export interface SeafarerRegistrationIssue {
kind: 'field' | 'document';
target: string;
message: string;
}

View File

@@ -14,8 +14,8 @@ const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const
/**
* The seafarer's evidence shelf: sea-service records and medical
* certificates (modules 05/06). Registration itself rides the licensing
* endpoints — a SEAFARER_REGISTRATION application through `licensingApi`.
* certificates (modules 05/06). Registration itself lives in
* `seafarer-registration-api.ts`.
*/
export const seafarerApi = baseApi
.enhanceEndpoints({ addTagTypes: TAGS })