mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
- 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.
284 lines
10 KiB
TypeScript
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;
|