Files
emaui/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage.tsx
Nati 818964a694 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.
2026-08-20 07:14:57 +00:00

284 lines
10 KiB
TypeScript

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;