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